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