gabriel / musehub public
test_intel_breakage.py python
783 lines 31.7 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago
1 """Breakage intel — full 7-tier test suite (issue #23).
2
3 Tests are written TDD-first: all tests must be RED before Phase 4–7
4 implementation begins, then GREEN after.
5
6 Tiers
7 -----
8 T01–T05 Layer T1 — DB model (columns, nullable, cascade, indexes, meta)
9 T06–T12 Layer T2 — Provider (no subprocess, stale detect, known sym bypass, module resolve bypass, empty, idempotent, meta)
10 T13–T19 Layer T3 — Route (200, empty state, 404, type filter, top filter, stat chips, bad input no 500)
11 T20–T24 Layer T4 — E2E HTML (severity badges, stat chips, file paths, dashboard card, empty state text)
12 T25–T28 Layer T5 — Data integrity (upsert idempotent, cross-repo isolation, severity stored, type index)
13 T29–T31 Layer T6 — Performance (provider speed, route speed, bulk upsert)
14 T32–T34 Layer T7 — Security (XSS in file_path, SQL injection top param, no 500 on bad type)
15 """
16 from __future__ import annotations
17
18 import time
19 from datetime import datetime, timezone
20 from unittest.mock import AsyncMock, patch
21
22 import msgpack
23 import pytest
24 import pytest_asyncio
25 import sqlalchemy as sa
26 from httpx import AsyncClient
27 from sqlalchemy.dialects.postgresql import insert as pg_insert
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from muse.core.types import long_id
31 from musehub.db import musehub_models as dbm
32 from tests.factories import create_repo
33
34 _REF = long_id("a" * 64)
35 _SNAP = long_id("b" * 64)
36 _CID = long_id("c" * 64)
37 _OBJ_1 = long_id("d" * 64)
38 _OBJ_2 = long_id("e" * 64)
39
40
41 # ─────────────────────────────────────────────────────────────────────────────
42 # Helpers
43 # ─────────────────────────────────────────────────────────────────────────────
44
45 async def _insert_issue(
46 session: AsyncSession,
47 repo_id: str,
48 issue_id: str,
49 file_path: str = "src/foo.py",
50 issue_type: str = "stale_import",
51 description: str = "imports 'blob_id' but no symbol or module with that name exists in the HEAD snapshot",
52 severity: str = "warning",
53 ref: str = "dev",
54 ) -> None:
55 await session.execute(
56 pg_insert(dbm.MusehubIntelBreakageIssue)
57 .values(
58 issue_id=issue_id,
59 repo_id=repo_id,
60 file_path=file_path,
61 issue_type=issue_type,
62 description=description,
63 severity=severity,
64 ref=ref,
65 )
66 .on_conflict_do_update(
67 index_elements=["issue_id"],
68 set_={
69 "file_path": file_path,
70 "issue_type": issue_type,
71 "description": description,
72 "severity": severity,
73 "ref": ref,
74 },
75 )
76 )
77
78
79 async def _insert_meta(
80 session: AsyncSession,
81 repo_id: str,
82 total_issues: int = 0,
83 warning_count: int = 0,
84 error_count: int = 0,
85 file_count: int = 0,
86 ref: str = "dev",
87 ) -> None:
88 await session.execute(
89 pg_insert(dbm.MusehubIntelBreakageMeta)
90 .values(
91 repo_id=repo_id,
92 total_issues=total_issues,
93 warning_count=warning_count,
94 error_count=error_count,
95 file_count=file_count,
96 ref=ref,
97 )
98 .on_conflict_do_update(
99 index_elements=["repo_id"],
100 set_={
101 "total_issues": total_issues,
102 "warning_count": warning_count,
103 "error_count": error_count,
104 "file_count": file_count,
105 "ref": ref,
106 },
107 )
108 )
109
110
111 async def _seed_commit(
112 session: AsyncSession,
113 repo_id: str,
114 manifest: dict[str, str],
115 owner: str,
116 slug: str,
117 ) -> None:
118 """Insert a single commit + snapshot (no parent needed for breakage)."""
119 await session.execute(
120 pg_insert(dbm.MusehubSnapshot)
121 .values(
122 snapshot_id = _SNAP,
123 repo_id = repo_id,
124 directories = [],
125 manifest_blob = msgpack.packb(manifest),
126 entry_count = len(manifest),
127 created_at = datetime(2026, 1, 1, tzinfo=timezone.utc),
128 )
129 .on_conflict_do_nothing()
130 )
131 await session.execute(
132 pg_insert(dbm.MusehubCommit)
133 .values(
134 commit_id = _CID,
135 repo_id = repo_id,
136 branch = "dev",
137 parent_ids = [],
138 message = "feat: some change",
139 author = owner,
140 timestamp = datetime(2026, 1, 1, tzinfo=timezone.utc),
141 snapshot_id = _SNAP,
142 )
143 .on_conflict_do_nothing()
144 )
145 await session.commit()
146
147
148 def _import_sym(
149 file_path: str,
150 symbol_name: str,
151 module_dotted: str,
152 ) -> tuple[str, dict]:
153 """Return ``(address, rec)`` for a parse_symbols import record."""
154 address = f"{file_path}::import::{symbol_name}"
155 return address, {
156 "kind": "import",
157 "name": symbol_name,
158 "qualified_name": f"import::{module_dotted}::{symbol_name}",
159 "content_id": long_id("1" * 64),
160 "body_hash": "",
161 "signature_id": "",
162 "metadata_id": "",
163 "canonical_key": f"{file_path}##import#{symbol_name}#1",
164 "lineno": 1,
165 "end_lineno": 1,
166 }
167
168
169 def _fn_sym(
170 file_path: str,
171 name: str,
172 ) -> tuple[str, dict]:
173 """Return ``(address, rec)`` for a parse_symbols function record."""
174 address = f"{file_path}::{name}"
175 return address, {
176 "kind": "function",
177 "name": name,
178 "qualified_name": name,
179 "content_id": long_id("2" * 64),
180 "body_hash": long_id("3" * 64),
181 "signature_id": long_id("4" * 64),
182 "metadata_id": "",
183 "canonical_key": f"{file_path}##function#{name}#1",
184 "lineno": 1,
185 "end_lineno": 5,
186 }
187
188
189 @pytest_asyncio.fixture
190 async def bk_repo(db_session: AsyncSession):
191 """Repo seeded with 5 breakage issue rows and a meta row."""
192 repo = await create_repo(db_session, owner="bkuser", slug="bk-e2e")
193 rid = str(repo.repo_id)
194
195 for i in range(5):
196 await _insert_issue(
197 db_session, rid,
198 issue_id=f"sha256:bk{'0' * 60}{i:02d}",
199 file_path=f"src/file_{i}.py",
200 description=f"imports 'sym_{i}' but no symbol or module with that name exists in the HEAD snapshot",
201 )
202 await _insert_meta(db_session, rid, total_issues=5, warning_count=5, file_count=5)
203 await db_session.commit()
204 return repo
205
206
207 # ─────────────────────────────────────────────────────────────────────────────
208 # Layer T1 — DB model
209 # ─────────────────────────────────────────────────────────────────────────────
210
211 class TestDBModel:
212
213 def test_T01_issue_model_has_required_columns(self) -> None:
214 """MusehubIntelBreakageIssue must expose all required columns."""
215 cols = {c.name for c in dbm.MusehubIntelBreakageIssue.__table__.columns}
216 for required in ("issue_id", "repo_id", "file_path", "issue_type", "description", "severity", "ref"):
217 assert required in cols, f"Column '{required}' missing"
218
219 def test_T02_meta_model_has_required_columns(self) -> None:
220 """MusehubIntelBreakageMeta must expose all required columns."""
221 cols = {c.name for c in dbm.MusehubIntelBreakageMeta.__table__.columns}
222 for required in ("repo_id", "total_issues", "warning_count", "error_count", "file_count", "ref"):
223 assert required in cols, f"Column '{required}' missing"
224
225 def test_T03_cascade_delete_on_issue(self) -> None:
226 """repo_id FK on issues table must use CASCADE."""
227 fks = dbm.MusehubIntelBreakageIssue.__table__.foreign_keys
228 for fk in fks:
229 if "repo_id" in str(fk.parent):
230 assert fk.ondelete == "CASCADE"
231 return
232 pytest.fail("No CASCADE FK found for repo_id on breakage issues")
233
234 def test_T04_cascade_delete_on_meta(self) -> None:
235 """repo_id FK on meta table must use CASCADE."""
236 fks = dbm.MusehubIntelBreakageMeta.__table__.foreign_keys
237 for fk in fks:
238 if "repo_id" in str(fk.parent):
239 assert fk.ondelete == "CASCADE"
240 return
241 pytest.fail("No CASCADE FK found for repo_id on breakage meta")
242
243 def test_T05_composite_index_exists(self) -> None:
244 """ix_intel_breakage_issues_repo_type index must cover (repo_id, issue_type)."""
245 indexes = dbm.MusehubIntelBreakageIssue.__table__.indexes
246 names = {idx.name for idx in indexes}
247 assert "ix_intel_breakage_issues_repo_type" in names
248
249
250 # ─────────────────────────────────────────────────────────────────────────────
251 # Layer T2 — Provider
252 # ─────────────────────────────────────────────────────────────────────────────
253
254 class TestProvider:
255
256 @pytest.mark.asyncio
257 async def test_T06_provider_uses_no_subprocess(
258 self, db_session: AsyncSession
259 ) -> None:
260 """BreakageProvider must never call _run_muse or import subprocess."""
261 from musehub.services import musehub_intel_providers as svc
262 import inspect, ast, textwrap
263 src = inspect.getsource(svc.BreakageProvider)
264 # Strip docstrings from AST so comment-only mentions don't trip us up
265 tree = ast.parse(textwrap.dedent(src))
266 for node in ast.walk(tree):
267 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
268 if (
269 node.body
270 and isinstance(node.body[0], ast.Expr)
271 and isinstance(node.body[0].value, ast.Constant)
272 ):
273 node.body = node.body[1:] or [ast.Pass()]
274 non_doc_src = ast.unparse(tree)
275 assert "_run_muse" not in non_doc_src, "BreakageProvider must not call _run_muse"
276 assert "subprocess" not in non_doc_src, "BreakageProvider must not use subprocess"
277 assert "create_subprocess" not in non_doc_src
278
279 @pytest.mark.asyncio
280 async def test_T07_provider_detects_stale_import(
281 self, db_session: AsyncSession
282 ) -> None:
283 """Provider must detect an import whose symbol doesn't exist anywhere."""
284 from musehub.services.musehub_intel_providers import BreakageProvider
285
286 repo = await create_repo(db_session, owner="bkp1", slug="bk-stale")
287 rid = str(repo.repo_id)
288 await _seed_commit(db_session, rid, {"src/a.py": _OBJ_1}, "bkp1", "bk-stale")
289
290 # src/a.py imports 'blob_id' from 'muse.core.types'
291 # 'muse/core/types.py' is NOT in the manifest
292 # 'blob_id' is NOT defined anywhere in the snapshot
293 file_tree = dict([
294 _import_sym("src/a.py", "blob_id", "muse.core.types"),
295 ])
296
297 mock_backend = AsyncMock()
298 mock_backend.get = AsyncMock(return_value=b"src content")
299
300 with (
301 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
302 patch("musehub.services.musehub_intel_providers.parse_symbols", return_value=file_tree),
303 ):
304 results = await BreakageProvider().compute(
305 db_session, rid, "dev", {"owner": "bkp1", "slug": "bk-stale"}
306 )
307
308 assert results, "Expected at least one result tuple"
309 count = results[0][1]["count"]
310 assert count == 1, f"Expected 1 stale import, got {count}"
311
312 row = (await db_session.execute(
313 sa.select(dbm.MusehubIntelBreakageIssue)
314 .where(dbm.MusehubIntelBreakageIssue.repo_id == rid)
315 )).scalars().first()
316 assert row is not None
317 assert row.issue_type == "stale_import"
318 assert "blob_id" in row.description
319
320 @pytest.mark.asyncio
321 async def test_T08_known_symbol_bypasses_stale_flag(
322 self, db_session: AsyncSession
323 ) -> None:
324 """Import of a symbol that exists somewhere in the snapshot is NOT stale."""
325 from musehub.services.musehub_intel_providers import BreakageProvider
326
327 repo = await create_repo(db_session, owner="bkp2", slug="bk-known")
328 rid = str(repo.repo_id)
329 await _seed_commit(
330 db_session, rid,
331 {"src/a.py": _OBJ_1, "src/b.py": _OBJ_2},
332 "bkp2", "bk-known",
333 )
334
335 # src/a.py imports 'my_fn' from 'src.b'
336 # src/b.py defines 'my_fn'
337 # → not stale
338 tree_a = dict([_import_sym("src/a.py", "my_fn", "src.b")])
339 tree_b = dict([_fn_sym("src/b.py", "my_fn")])
340
341 mock_backend = AsyncMock()
342 mock_backend.get = AsyncMock(side_effect=[b"a", b"b"])
343
344 with (
345 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
346 patch("musehub.services.musehub_intel_providers.parse_symbols",
347 side_effect=[tree_a, tree_b]),
348 ):
349 results = await BreakageProvider().compute(
350 db_session, rid, "dev", {"owner": "bkp2", "slug": "bk-known"}
351 )
352
353 count = results[0][1]["count"]
354 assert count == 0, f"Expected 0 issues (symbol exists), got {count}"
355
356 @pytest.mark.asyncio
357 async def test_T09_resolved_module_bypasses_stale_flag(
358 self, db_session: AsyncSession
359 ) -> None:
360 """Import whose module path resolves to a tracked file is NOT stale."""
361 from musehub.services.musehub_intel_providers import BreakageProvider
362
363 repo = await create_repo(db_session, owner="bkp3", slug="bk-resolve")
364 rid = str(repo.repo_id)
365 # Manifest includes src/b.py — so 'src.b' resolves
366 await _seed_commit(
367 db_session, rid,
368 {"src/a.py": _OBJ_1, "src/b.py": _OBJ_2},
369 "bkp3", "bk-resolve",
370 )
371
372 # src/a.py imports 'anything' from 'src.b' — module resolves → not stale
373 tree_a = dict([_import_sym("src/a.py", "anything", "src.b")])
374 tree_b = dict([_fn_sym("src/b.py", "other_fn")])
375
376 mock_backend = AsyncMock()
377 mock_backend.get = AsyncMock(side_effect=[b"a", b"b"])
378
379 with (
380 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
381 patch("musehub.services.musehub_intel_providers.parse_symbols",
382 side_effect=[tree_a, tree_b]),
383 ):
384 results = await BreakageProvider().compute(
385 db_session, rid, "dev", {"owner": "bkp3", "slug": "bk-resolve"}
386 )
387
388 count = results[0][1]["count"]
389 assert count == 0, f"Expected 0 issues (module resolved), got {count}"
390
391 @pytest.mark.asyncio
392 async def test_T10_provider_returns_empty_on_empty_manifest(
393 self, db_session: AsyncSession
394 ) -> None:
395 """Provider must return [(intel_type, {count: 0})] on empty snapshot."""
396 from musehub.services.musehub_intel_providers import BreakageProvider
397
398 repo = await create_repo(db_session, owner="bkp4", slug="bk-empty")
399 rid = str(repo.repo_id)
400 await _seed_commit(db_session, rid, {}, "bkp4", "bk-empty")
401
402 results = await BreakageProvider().compute(
403 db_session, rid, "dev", {"owner": "bkp4", "slug": "bk-empty"}
404 )
405 assert results[0][1]["count"] == 0
406
407 @pytest.mark.asyncio
408 async def test_T11_provider_is_idempotent(
409 self, db_session: AsyncSession
410 ) -> None:
411 """Running the provider twice must produce exactly 1 row, not 2."""
412 from musehub.services.musehub_intel_providers import BreakageProvider
413
414 repo = await create_repo(db_session, owner="bkp5", slug="bk-idempotent")
415 rid = str(repo.repo_id)
416 await _seed_commit(db_session, rid, {"src/a.py": _OBJ_1}, "bkp5", "bk-idempotent")
417
418 file_tree = dict([_import_sym("src/a.py", "gone_fn", "gone.module")])
419 mock_backend = AsyncMock()
420 mock_backend.get = AsyncMock(return_value=b"content")
421
422 for _ in range(2):
423 with (
424 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
425 patch("musehub.services.musehub_intel_providers.parse_symbols", return_value=file_tree),
426 ):
427 await BreakageProvider().compute(
428 db_session, rid, "dev", {"owner": "bkp5", "slug": "bk-idempotent"}
429 )
430
431 rows = (await db_session.execute(
432 sa.select(dbm.MusehubIntelBreakageIssue)
433 .where(dbm.MusehubIntelBreakageIssue.repo_id == rid)
434 )).scalars().all()
435 assert len(rows) == 1, f"Expected 1 row after 2 runs, got {len(rows)}"
436
437 @pytest.mark.asyncio
438 async def test_T12_provider_writes_meta_row(
439 self, db_session: AsyncSession
440 ) -> None:
441 """Provider must upsert a meta row with accurate counts."""
442 from musehub.services.musehub_intel_providers import BreakageProvider
443
444 repo = await create_repo(db_session, owner="bkp6", slug="bk-meta")
445 rid = str(repo.repo_id)
446 await _seed_commit(
447 db_session, rid,
448 {"src/a.py": _OBJ_1, "src/b.py": _OBJ_2},
449 "bkp6", "bk-meta",
450 )
451
452 tree_a = dict([
453 _import_sym("src/a.py", "stale1", "gone.one"),
454 _import_sym("src/a.py", "stale2", "gone.two"),
455 ])
456 tree_b = dict([_import_sym("src/b.py", "stale3", "gone.three")])
457 mock_backend = AsyncMock()
458 mock_backend.get = AsyncMock(side_effect=[b"a", b"b"])
459
460 with (
461 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
462 patch("musehub.services.musehub_intel_providers.parse_symbols",
463 side_effect=[tree_a, tree_b]),
464 ):
465 await BreakageProvider().compute(
466 db_session, rid, "dev", {"owner": "bkp6", "slug": "bk-meta"}
467 )
468
469 meta = (await db_session.execute(
470 sa.select(dbm.MusehubIntelBreakageMeta)
471 .where(dbm.MusehubIntelBreakageMeta.repo_id == rid)
472 )).scalars().first()
473 assert meta is not None, "Meta row not written"
474 assert meta.total_issues == 3
475 assert meta.warning_count == 3
476 assert meta.file_count == 2
477
478
479 # ─────────────────────────────────────────────────────────────────────────────
480 # Layer T3 — Route
481 # ─────────────────────────────────────────────────────────────────────────────
482
483 class TestRoute:
484
485 @pytest.mark.asyncio
486 async def test_T13_breakage_page_returns_200(
487 self, client: AsyncClient, bk_repo
488 ) -> None:
489 """GET /bkuser/bk-e2e/intel/breakage must return HTTP 200."""
490 resp = await client.get("/bkuser/bk-e2e/intel/breakage")
491 assert resp.status_code == 200, resp.text[:500]
492
493 @pytest.mark.asyncio
494 async def test_T14_breakage_page_empty_state(
495 self, client: AsyncClient, db_session: AsyncSession
496 ) -> None:
497 """Route must render empty state when no issue rows exist."""
498 repo = await create_repo(db_session, owner="bkempty", slug="bk-nodata")
499 await db_session.commit()
500 resp = await client.get("/bkempty/bk-nodata/intel/breakage")
501 assert resp.status_code == 200
502 assert "Push a commit" in resp.text
503
504 @pytest.mark.asyncio
505 async def test_T15_breakage_page_404_for_unknown_repo(
506 self, client: AsyncClient
507 ) -> None:
508 """Route must return 404 for an unknown repo slug."""
509 resp = await client.get("/nobody/nonexistent-repo/intel/breakage")
510 assert resp.status_code == 404
511
512 @pytest.mark.asyncio
513 async def test_T16_type_filter_limits_results(
514 self, client: AsyncClient, bk_repo
515 ) -> None:
516 """?type=stale_import must return 200 and include stale_import rows."""
517 resp = await client.get("/bkuser/bk-e2e/intel/breakage?type=stale_import")
518 assert resp.status_code == 200
519 assert "stale_import" in resp.text
520
521 @pytest.mark.asyncio
522 async def test_T17_top_filter_limits_results(
523 self, client: AsyncClient, bk_repo
524 ) -> None:
525 """?top=2 must return 200 and limit the issue list."""
526 resp = await client.get("/bkuser/bk-e2e/intel/breakage?top=2")
527 assert resp.status_code == 200
528
529 @pytest.mark.asyncio
530 async def test_T18_stat_chips_present_in_html(
531 self, client: AsyncClient, bk_repo
532 ) -> None:
533 """Response must include the stat chip elements."""
534 resp = await client.get("/bkuser/bk-e2e/intel/breakage")
535 assert "bk-stat-val" in resp.text
536
537 @pytest.mark.asyncio
538 async def test_T19_invalid_top_does_not_500(
539 self, client: AsyncClient, bk_repo
540 ) -> None:
541 """?top=GARBAGE must return 200 and fall back to default top."""
542 resp = await client.get("/bkuser/bk-e2e/intel/breakage?top=GARBAGE")
543 assert resp.status_code == 200
544
545
546 # ─────────────────────────────────────────────────────────────────────────────
547 # Layer T4 — E2E HTML
548 # ─────────────────────────────────────────────────────────────────────────────
549
550 class TestHTML:
551
552 @pytest.mark.asyncio
553 async def test_T20_severity_badge_in_html(
554 self, client: AsyncClient, bk_repo
555 ) -> None:
556 """Each issue row must include a severity badge element."""
557 resp = await client.get("/bkuser/bk-e2e/intel/breakage")
558 assert "bk-sev-badge" in resp.text
559
560 @pytest.mark.asyncio
561 async def test_T21_stat_chips_rendered(
562 self, client: AsyncClient, bk_repo
563 ) -> None:
564 """Stat chips for Total and Warnings must appear in the page."""
565 resp = await client.get("/bkuser/bk-e2e/intel/breakage")
566 html = resp.text
567 assert "bk-stat-val" in html
568 assert "bk-stat-lbl" in html
569
570 @pytest.mark.asyncio
571 async def test_T22_file_paths_rendered_in_rows(
572 self, client: AsyncClient, bk_repo
573 ) -> None:
574 """Each issue row must display the file_path."""
575 resp = await client.get("/bkuser/bk-e2e/intel/breakage")
576 html = resp.text
577 assert "src/file_0.py" in html
578
579 @pytest.mark.asyncio
580 async def test_T23_empty_state_has_helpful_message(
581 self, client: AsyncClient, db_session: AsyncSession
582 ) -> None:
583 """Empty-state div must contain a push-prompt message."""
584 repo = await create_repo(db_session, owner="bkempty2", slug="bk-empty2")
585 await db_session.commit()
586 resp = await client.get("/bkempty2/bk-empty2/intel/breakage")
587 assert "Push a commit" in resp.text
588
589 @pytest.mark.asyncio
590 async def test_T24_dashboard_card_links_to_breakage_page(
591 self, client: AsyncClient, bk_repo
592 ) -> None:
593 """Intel dashboard must include a card linking to the breakage page."""
594 resp = await client.get("/bkuser/bk-e2e/intel")
595 html = resp.text
596 assert "breakage" in html.lower()
597
598
599 # ─────────────────────────────────────────────────────────────────────────────
600 # Layer T5 — Data integrity
601 # ─────────────────────────────────────────────────────────────────────────────
602
603 class TestDataIntegrity:
604
605 @pytest.mark.asyncio
606 async def test_T25_upsert_is_idempotent(
607 self, db_session: AsyncSession
608 ) -> None:
609 """Inserting the same issue_id twice must yield exactly one row."""
610 repo = await create_repo(db_session, owner="bkdi1", slug="bk-di1")
611 rid = str(repo.repo_id)
612 iid = long_id("f" * 64)
613
614 for _ in range(2):
615 await _insert_issue(db_session, rid, issue_id=iid)
616 await db_session.commit()
617
618 rows = (await db_session.execute(
619 sa.select(dbm.MusehubIntelBreakageIssue)
620 .where(dbm.MusehubIntelBreakageIssue.repo_id == rid)
621 )).scalars().all()
622 assert len(rows) == 1
623
624 @pytest.mark.asyncio
625 async def test_T26_cross_repo_isolation(
626 self, db_session: AsyncSession
627 ) -> None:
628 """Issues from repo A must not appear in repo B queries."""
629 repo_a = await create_repo(db_session, owner="bkdi2a", slug="bk-a")
630 repo_b = await create_repo(db_session, owner="bkdi2b", slug="bk-b")
631 rid_a = str(repo_a.repo_id)
632 rid_b = str(repo_b.repo_id)
633
634 await _insert_issue(db_session, rid_a, long_id("a" * 64), file_path="src/a.py")
635 await db_session.commit()
636
637 rows_b = (await db_session.execute(
638 sa.select(dbm.MusehubIntelBreakageIssue)
639 .where(dbm.MusehubIntelBreakageIssue.repo_id == rid_b)
640 )).scalars().all()
641 assert len(rows_b) == 0
642
643 @pytest.mark.asyncio
644 async def test_T27_severity_stored_correctly(
645 self, db_session: AsyncSession
646 ) -> None:
647 """Rows must preserve the severity value written by the provider."""
648 repo = await create_repo(db_session, owner="bkdi3", slug="bk-sev")
649 rid = str(repo.repo_id)
650
651 await _insert_issue(db_session, rid, long_id("e" * 64), severity="error")
652 await db_session.commit()
653
654 row = (await db_session.execute(
655 sa.select(dbm.MusehubIntelBreakageIssue)
656 .where(dbm.MusehubIntelBreakageIssue.repo_id == rid)
657 )).scalars().first()
658 assert row is not None
659 assert row.severity == "error"
660
661 @pytest.mark.asyncio
662 async def test_T28_type_index_exists(self) -> None:
663 """ix_intel_breakage_issues_repo_type index must be present in ORM."""
664 indexes = dbm.MusehubIntelBreakageIssue.__table__.indexes
665 names = {idx.name for idx in indexes}
666 assert "ix_intel_breakage_issues_repo_type" in names
667
668
669 # ─────────────────────────────────────────────────────────────────────────────
670 # Layer T6 — Performance
671 # ─────────────────────────────────────────────────────────────────────────────
672
673 class TestPerformance:
674
675 @pytest.mark.asyncio
676 async def test_T29_provider_completes_under_5s(
677 self, db_session: AsyncSession
678 ) -> None:
679 """BreakageProvider must complete in under 5 seconds for 50 files."""
680 from musehub.services.musehub_intel_providers import BreakageProvider
681
682 repo = await create_repo(db_session, owner="bkperf1", slug="bk-perf1")
683 rid = str(repo.repo_id)
684
685 manifest = {f"src/file_{i}.py": long_id("a" * 62 + f"{i:02d}") for i in range(50)}
686 await _seed_commit(db_session, rid, manifest, "bkperf1", "bk-perf1")
687
688 file_trees = [
689 dict([_import_sym(f"src/file_{i}.py", "gone_fn", "gone.module")])
690 for i in range(50)
691 ]
692 mock_backend = AsyncMock()
693 mock_backend.get = AsyncMock(return_value=b"content")
694
695 t0 = time.monotonic()
696 with (
697 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
698 patch("musehub.services.musehub_intel_providers.parse_symbols",
699 side_effect=file_trees),
700 ):
701 await BreakageProvider().compute(
702 db_session, rid, "dev", {"owner": "bkperf1", "slug": "bk-perf1"}
703 )
704 elapsed = time.monotonic() - t0
705 assert elapsed < 5.0, f"Provider took {elapsed:.2f}s (> 5s limit)"
706
707 @pytest.mark.asyncio
708 async def test_T30_route_responds_under_2s(
709 self, client: AsyncClient, bk_repo
710 ) -> None:
711 """GET /intel/breakage must respond in under 2 seconds."""
712 t0 = time.monotonic()
713 resp = await client.get("/bkuser/bk-e2e/intel/breakage")
714 elapsed = time.monotonic() - t0
715 assert resp.status_code == 200
716 assert elapsed < 2.0, f"Route took {elapsed:.2f}s (> 2s limit)"
717
718 @pytest.mark.asyncio
719 async def test_T31_bulk_upsert_500_issues(
720 self, db_session: AsyncSession
721 ) -> None:
722 """Inserting 500 issues via upsert must not raise."""
723 repo = await create_repo(db_session, owner="bkperf3", slug="bk-bulk")
724 rid = str(repo.repo_id)
725
726 for i in range(500):
727 hex_i = format(i, "060x")
728 await _insert_issue(
729 db_session, rid,
730 issue_id=long_id(hex_i[:64]),
731 file_path=f"src/f{i}.py",
732 )
733 await db_session.commit()
734
735 count = (await db_session.execute(
736 sa.select(sa.func.count())
737 .select_from(dbm.MusehubIntelBreakageIssue)
738 .where(dbm.MusehubIntelBreakageIssue.repo_id == rid)
739 )).scalar_one()
740 assert count == 500
741
742
743 # ─────────────────────────────────────────────────────────────────────────────
744 # Layer T7 — Security
745 # ─────────────────────────────────────────────────────────────────────────────
746
747 class TestSecurity:
748
749 @pytest.mark.asyncio
750 async def test_T32_xss_in_file_path_is_escaped(
751 self, client: AsyncClient, db_session: AsyncSession
752 ) -> None:
753 """file_path with <script> must be HTML-escaped in the response."""
754 repo = await create_repo(db_session, owner="bksec1", slug="bk-xss")
755 rid = str(repo.repo_id)
756 xss = "<script>alert('xss')</script>"
757 await _insert_issue(
758 db_session, rid,
759 issue_id=long_id("x" * 64),
760 file_path=xss,
761 description="imports 'fn' but no symbol or module with that name exists in the HEAD snapshot",
762 )
763 await db_session.commit()
764
765 resp = await client.get("/bksec1/bk-xss/intel/breakage")
766 assert resp.status_code == 200
767 assert "<script>alert" not in resp.text
768
769 @pytest.mark.asyncio
770 async def test_T33_sql_injection_in_top_param(
771 self, client: AsyncClient, bk_repo
772 ) -> None:
773 """?top=1; DROP TABLE must return 200 without crashing."""
774 resp = await client.get("/bkuser/bk-e2e/intel/breakage?top=1;DROP TABLE")
775 assert resp.status_code == 200
776
777 @pytest.mark.asyncio
778 async def test_T34_unknown_type_does_not_500(
779 self, client: AsyncClient, bk_repo
780 ) -> None:
781 """?type=unknown_evil must return 200 with a safe fallback."""
782 resp = await client.get("/bkuser/bk-e2e/intel/breakage?type=unknown_evil")
783 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