gabriel / musehub public
test_intel_api_surface.py python
722 lines 29.8 KB
Raw
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 102 days ago
1 """API Surface dashboard — full 7-tier test suite (issue #19).
2
3 Tests are written TDD-first: all tests in this file must be RED before
4 Phase 3–5 implementation begins, then GREEN after.
5
6 Tiers:
7 T01–T03 Layer T1 — DB model (composite PK, nullable fields, cascade)
8 T04–T06 Layer T2 — Provider batch performance
9 T07–T15 Layer T3 — Route (unit / integration)
10 T16–T19 Layer T4 — E2E (HTML body assertions)
11 T20–T22 Layer T5 — State integrity
12 T23–T25 Layer T6 — Performance
13 T26–T30 Layer T7 — Security
14 """
15 from __future__ import annotations
16
17 import time
18 from unittest.mock import AsyncMock, patch
19
20 import typing
21
22 import pytest
23 import sqlalchemy as sa
24 from httpx import AsyncClient
25 from sqlalchemy.engine import CursorResult
26 from sqlalchemy.dialects.postgresql import insert as pg_insert
27 from sqlalchemy.ext.asyncio import AsyncSession
28
29 from musehub.db.musehub_intel_models import MusehubIntelApiSurface
30 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo, MusehubSnapshot, MusehubSnapshotRef
31 from musehub.types.json_types import JSONObject
32 from tests.factories import create_repo
33 from muse.core.types import long_id
34
35 _REF = long_id("b" * 64)
36
37
38 # ---------------------------------------------------------------------------
39 # Helpers
40 # ---------------------------------------------------------------------------
41
42 async def _insert_as_row(
43 session: AsyncSession,
44 repo_id: str,
45 address: str,
46 kind: str = "function",
47 signature_id: str | None = None,
48 visibility: str = "public",
49 ref: str = _REF,
50 ) -> None:
51 """Upsert one row into musehub_intel_api_surface."""
52 await session.execute(
53 pg_insert(MusehubIntelApiSurface)
54 .values(
55 repo_id=repo_id,
56 address=address,
57 kind=kind,
58 signature_id=signature_id,
59 visibility=visibility,
60 ref=ref,
61 )
62 .on_conflict_do_update(
63 index_elements=["repo_id", "address"],
64 set_={
65 "kind": kind,
66 "signature_id": signature_id,
67 "visibility": visibility,
68 "ref": ref,
69 },
70 )
71 )
72
73
74 import pytest_asyncio
75
76
77 @pytest_asyncio.fixture
78 async def as_repo(db_session: AsyncSession) -> MusehubRepo:
79 """Repo seeded with one symbol of each kind."""
80 repo = await create_repo(db_session, owner="asuser", slug="as-e2e")
81 rid = str(repo.repo_id)
82
83 await _insert_as_row(db_session, rid, "src/billing.py::compute_total",
84 kind="function")
85 await _insert_as_row(db_session, rid, "src/billing.py::async_fetch",
86 kind="async_function")
87 await _insert_as_row(db_session, rid, "src/models.py::UserRecord",
88 kind="class")
89 await _insert_as_row(db_session, rid, "src/models.py::UserRecord.save",
90 kind="method")
91 await _insert_as_row(db_session, rid, "src/models.py::UserRecord.async_load",
92 kind="async_method")
93
94 await db_session.commit()
95 return repo
96
97
98 # ─────────────────────────────────────────────────────────────────────────────
99 # Layer T1 — DB model
100 # ─────────────────────────────────────────────────────────────────────────────
101
102 class TestDBModel:
103
104 def test_T01_model_has_required_columns(self) -> None:
105 """MusehubIntelApiSurface must declare all expected mapped columns."""
106 cols = {c.key for c in sa.inspect(MusehubIntelApiSurface).mapper.column_attrs}
107 for required in ("repo_id", "address", "kind", "signature_id", "visibility", "ref"):
108 assert required in cols, f"Column '{required}' missing from MusehubIntelApiSurface"
109
110 def test_T02_signature_id_is_nullable(self) -> None:
111 """signature_id must be nullable — not all symbols have a signature object."""
112 col = MusehubIntelApiSurface.__table__.c["signature_id"]
113 assert col.nullable, "signature_id must be nullable"
114
115 @pytest.mark.asyncio
116 async def test_T03_row_insert_and_cascade_delete(
117 self, db_session: AsyncSession
118 ) -> None:
119 """Row inserts cleanly; deleting the repo cascades to api_surface rows."""
120 repo = await create_repo(db_session, owner="asuser", slug="t03-cascade")
121 rid = str(repo.repo_id)
122 await _insert_as_row(db_session, rid, "src/x.py::fn")
123 await db_session.commit()
124
125 # row present
126 row = await db_session.scalar(
127 sa.select(MusehubIntelApiSurface).where(
128 MusehubIntelApiSurface.repo_id == rid,
129 MusehubIntelApiSurface.address == "src/x.py::fn",
130 )
131 )
132 assert row is not None, "Row not found after insert"
133
134 # cascade delete
135 await db_session.delete(repo)
136 await db_session.commit()
137
138 remaining = (await db_session.execute(
139 sa.select(MusehubIntelApiSurface).where(
140 MusehubIntelApiSurface.repo_id == rid
141 )
142 )).scalars().all()
143 assert not remaining, "Cascade delete failed — api_surface rows remain after repo delete"
144
145
146 # ─────────────────────────────────────────────────────────────────────────────
147 # Layer T2 — Provider batch performance
148 # ─────────────────────────────────────────────────────────────────────────────
149
150 async def _seed_snapshot(
151 session: AsyncSession,
152 repo_id: str,
153 manifest: dict[str, str],
154 ) -> str:
155 """Insert a MusehubCommit + MusehubSnapshot and return the snapshot_id."""
156 import msgpack
157 from datetime import datetime, timezone
158
159 snap_id = long_id("c" * 64)
160 commit_id = long_id("d" * 64)
161
162 await session.execute(
163 pg_insert(MusehubSnapshot)
164 .values(
165 snapshot_id=snap_id,
166 directories=[],
167 manifest_blob=msgpack.packb(manifest),
168 entry_count=len(manifest),
169 created_at=datetime.now(timezone.utc),
170 )
171 .on_conflict_do_nothing()
172 )
173 await session.execute(
174 pg_insert(MusehubSnapshotRef)
175 .values(repo_id=repo_id, snapshot_id=snap_id)
176 .on_conflict_do_nothing()
177 )
178 await session.execute(
179 pg_insert(MusehubCommit)
180 .values(
181 commit_id=commit_id,
182 branch="dev",
183 parent_ids=[],
184 message="test",
185 author="asuser",
186 timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
187 snapshot_id=snap_id,
188 )
189 .on_conflict_do_nothing()
190 )
191 await session.execute(
192 pg_insert(MusehubCommitRef)
193 .values(repo_id=repo_id, commit_id=commit_id)
194 .on_conflict_do_nothing()
195 )
196 await session.commit()
197 return snap_id
198
199
200 def _fake_tree(n: int, prefix: str = "fn") -> JSONObject:
201 """Return a SymbolTree dict with *n* public function symbols."""
202 return {
203 f"src/file.py::{prefix}_{i}": {
204 "kind": "function",
205 "name": f"{prefix}_{i}",
206 "qualified_name": f"{prefix}_{i}",
207 "content_id": long_id("a" * 64),
208 "body_hash": long_id("b" * 64),
209 "signature_id": long_id("c" * 64),
210 "metadata_id": "",
211 "canonical_key": f"src/file.py##function#{prefix}_{i}#1",
212 "lineno": i + 1,
213 "end_lineno": i + 2,
214 }
215 for i in range(n)
216 }
217
218
219 class TestProviderBatch:
220
221 @pytest.mark.asyncio
222 async def test_T04_provider_issues_one_sql_per_chunk(
223 self, db_session: AsyncSession
224 ) -> None:
225 """ApiSurfaceProvider must batch-upsert, not execute one statement per symbol."""
226 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
227
228 repo = await create_repo(db_session, owner="asuser", slug="t04-batch")
229 rid = str(repo.repo_id)
230 await _seed_snapshot(db_session, rid, {"src/file.py": long_id("e" * 64)})
231
232 execute_calls: list[sa.Executable] = []
233 original_execute = db_session.execute
234
235 async def counting_execute(stmt: sa.Executable, *args: typing.Any, **kwargs: typing.Any) -> CursorResult[typing.Any]:
236 execute_calls.append(stmt)
237 return await original_execute(stmt, *args, **kwargs)
238
239 mock_backend = AsyncMock()
240 mock_backend.get = AsyncMock(return_value=b"# placeholder")
241
242 with (
243 patch("musehub.services.musehub_intel_providers.get_backend",
244 return_value=mock_backend),
245 patch("musehub.services.musehub_intel_providers.parse_symbols",
246 return_value=_fake_tree(50)),
247 ):
248 db_session.execute = counting_execute # type: ignore[method-assign]
249 await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
250 db_session, rid, _REF,
251 {"owner": repo.owner, "slug": repo.slug},
252 )
253 db_session.execute = original_execute # type: ignore[method-assign]
254
255 # 50 symbols fit in one chunk — expect exactly 1 INSERT execute
256 insert_calls = [
257 c for c in execute_calls
258 if "insert" in str(type(c).__name__).lower() or "insert" in str(c).lower()
259 ]
260 assert len(insert_calls) == 1, (
261 f"Expected 1 batch upsert for 50 symbols, got {len(insert_calls)}"
262 )
263
264 @pytest.mark.asyncio
265 async def test_T05_provider_uses_ceil_n_over_1000_sql_calls_for_2500_symbols(
266 self, db_session: AsyncSession
267 ) -> None:
268 """2,500 symbols → exactly 3 INSERT statements (ceil(2500/1000) = 3)."""
269 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
270
271 repo = await create_repo(db_session, owner="asuser", slug="t05-chunks")
272 rid = str(repo.repo_id)
273 await _seed_snapshot(db_session, rid, {"src/big.py": long_id("f" * 64)})
274
275 execute_calls: list[sa.Executable] = []
276 original_execute = db_session.execute
277
278 async def counting_execute(stmt: sa.Executable, *args: typing.Any, **kwargs: typing.Any) -> CursorResult[typing.Any]:
279 execute_calls.append(stmt)
280 return await original_execute(stmt, *args, **kwargs)
281
282 mock_backend = AsyncMock()
283 mock_backend.get = AsyncMock(return_value=b"# placeholder")
284
285 with (
286 patch("musehub.services.musehub_intel_providers.get_backend",
287 return_value=mock_backend),
288 patch("musehub.services.musehub_intel_providers.parse_symbols",
289 return_value=_fake_tree(2500)),
290 ):
291 db_session.execute = counting_execute # type: ignore[method-assign]
292 result = await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
293 db_session, rid, _REF,
294 {"owner": repo.owner, "slug": repo.slug},
295 )
296 db_session.execute = original_execute # type: ignore[method-assign]
297
298 insert_calls = [
299 c for c in execute_calls
300 if "insert" in str(type(c).__name__).lower() or "insert" in str(c).lower()
301 ]
302 assert len(insert_calls) == 3, (
303 f"2500 symbols should produce 3 INSERT chunks, got {len(insert_calls)}"
304 )
305 assert result == [("intel.code.api_surface", {"count": 2500})]
306
307 @pytest.mark.asyncio
308 async def test_T06_empty_symbols_returns_empty_list(
309 self, db_session: AsyncSession
310 ) -> None:
311 """Provider must return [] and issue no INSERTs when parse_symbols yields nothing."""
312 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
313
314 repo = await create_repo(db_session, owner="asuser", slug="t06-empty")
315 rid = str(repo.repo_id)
316 await _seed_snapshot(db_session, rid, {"src/empty.py": long_id("a" * 64)})
317
318 execute_calls: list[sa.Executable] = []
319 original_execute = db_session.execute
320
321 async def counting_execute(stmt: sa.Executable, *args: typing.Any, **kwargs: typing.Any) -> CursorResult[typing.Any]:
322 execute_calls.append(stmt)
323 return await original_execute(stmt, *args, **kwargs)
324
325 mock_backend = AsyncMock()
326 mock_backend.get = AsyncMock(return_value=b"# no public symbols")
327
328 with (
329 patch("musehub.services.musehub_intel_providers.get_backend",
330 return_value=mock_backend),
331 patch("musehub.services.musehub_intel_providers.parse_symbols",
332 return_value={}),
333 ):
334 db_session.execute = counting_execute # type: ignore[method-assign]
335 result = await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
336 db_session, rid, _REF,
337 {"owner": repo.owner, "slug": repo.slug},
338 )
339 db_session.execute = original_execute # type: ignore[method-assign]
340
341 assert result == [], "Empty symbols list must return []"
342 insert_calls = [c for c in execute_calls if "insert" in str(c).lower()]
343 assert len(insert_calls) == 0, "No DB writes expected for empty symbol list"
344
345
346 # ─────────────────────────────────────────────────────────────────────────────
347 # Layer T3 — Route (unit / integration)
348 # ─────────────────────────────────────────────────────────────────────────────
349
350 class TestRoute:
351
352 @pytest.mark.asyncio
353 async def test_T07_returns_200_with_empty_repo(
354 self, client: AsyncClient, db_session: AsyncSession
355 ) -> None:
356 """Route must return 200 even when musehub_intel_api_surface has no rows."""
357 await create_repo(db_session, owner="asuser", slug="t07-empty")
358 await db_session.commit()
359 r = await client.get("/asuser/t07-empty/intel/api-surface")
360 assert r.status_code == 200
361
362 @pytest.mark.asyncio
363 async def test_T08_returns_200_with_data(
364 self, client: AsyncClient, as_repo: MusehubRepo
365 ) -> None:
366 """Route returns 200 when rows exist."""
367 r = await client.get("/asuser/as-e2e/intel/api-surface")
368 assert r.status_code == 200
369
370 @pytest.mark.asyncio
371 async def test_T09_kind_filter_function_only(
372 self, client: AsyncClient, as_repo: MusehubRepo
373 ) -> None:
374 """?kind=function returns only function symbols, not class or method."""
375 r = await client.get("/asuser/as-e2e/intel/api-surface?kind=function")
376 assert r.status_code == 200
377 assert "compute_total" in r.text
378 assert "UserRecord.save" not in r.text
379 assert "UserRecord" not in r.text or "compute_total" in r.text
380
381 @pytest.mark.asyncio
382 async def test_T10_kind_filter_class_only(
383 self, client: AsyncClient, as_repo: MusehubRepo
384 ) -> None:
385 """?kind=class returns only class symbols."""
386 r = await client.get("/asuser/as-e2e/intel/api-surface?kind=class")
387 assert r.status_code == 200
388 assert "UserRecord" in r.text
389 assert "compute_total" not in r.text
390
391 @pytest.mark.asyncio
392 async def test_T11_kind_filter_async_function(
393 self, client: AsyncClient, as_repo: MusehubRepo
394 ) -> None:
395 """?kind=async_function returns only async_function symbols."""
396 r = await client.get("/asuser/as-e2e/intel/api-surface?kind=async_function")
397 assert r.status_code == 200
398 assert "async_fetch" in r.text
399 assert "compute_total" not in r.text
400
401 @pytest.mark.asyncio
402 async def test_T12_unknown_kind_coerced_to_all(
403 self, client: AsyncClient, as_repo: MusehubRepo
404 ) -> None:
405 """?kind=garbage must return 200 (treated as no filter), not 400/500."""
406 r = await client.get("/asuser/as-e2e/intel/api-surface?kind=garbage")
407 assert r.status_code == 200
408
409 @pytest.mark.asyncio
410 async def test_T13_top_param_limits_results(
411 self, client: AsyncClient, db_session: AsyncSession
412 ) -> None:
413 """?top=20 returns at most 20 symbols even when 25 exist."""
414 repo = await create_repo(db_session, owner="asuser", slug="t13-top")
415 rid = str(repo.repo_id)
416 for i in range(25):
417 await _insert_as_row(db_session, rid,
418 f"src/f{i}.py::fn_{i}", kind="function")
419 await db_session.commit()
420
421 r = await client.get("/asuser/t13-top/intel/api-surface?top=20")
422 assert r.status_code == 200
423 count = sum(1 for i in range(25) if f"src/f{i}.py::fn_{i}" in r.text)
424 assert count <= 20, f"Expected ≤20 results for ?top=20, got {count}"
425
426 @pytest.mark.asyncio
427 async def test_T14_top_invalid_string_returns_422(
428 self, client: AsyncClient, as_repo: MusehubRepo
429 ) -> None:
430 """?top=notanumber must be rejected with 422 (FastAPI type validation)."""
431 r = await client.get("/asuser/as-e2e/intel/api-surface?top=notanumber")
432 assert r.status_code == 422
433
434 @pytest.mark.asyncio
435 async def test_T15_unknown_repo_returns_404(
436 self, client: AsyncClient
437 ) -> None:
438 """Non-existent repo path must return 404, not 200 or 500."""
439 r = await client.get("/nobody/no-such-repo/intel/api-surface")
440 assert r.status_code in (403, 404)
441
442
443 # ─────────────────────────────────────────────────────────────────────────────
444 # Layer T4 — E2E (HTML body assertions)
445 # ─────────────────────────────────────────────────────────────────────────────
446
447 class TestE2E:
448
449 @pytest.mark.asyncio
450 async def test_T16_total_count_chip_shows_correct_value(
451 self, client: AsyncClient, as_repo: MusehubRepo
452 ) -> None:
453 """Stat chip for Total must reflect the DB row count (5 symbols seeded)."""
454 r = await client.get("/asuser/as-e2e/intel/api-surface")
455 assert r.status_code == 200
456 # 5 symbols seeded in fixture; total chip must contain "5"
457 assert "5" in r.text
458
459 @pytest.mark.asyncio
460 async def test_T17_kind_breakdown_chips_present(
461 self, client: AsyncClient, as_repo: MusehubRepo
462 ) -> None:
463 """Kind breakdown stat chips must appear for all five kinds."""
464 r = await client.get("/asuser/as-e2e/intel/api-surface")
465 assert r.status_code == 200
466 body = r.text.lower()
467 for kind_label in ("function", "class", "method"):
468 assert kind_label in body, f"Kind label '{kind_label}' missing from page"
469
470 @pytest.mark.asyncio
471 async def test_T18_symbol_address_split_rendered(
472 self, client: AsyncClient, as_repo: MusehubRepo
473 ) -> None:
474 """Symbol file and name parts must both appear in the HTML."""
475 r = await client.get("/asuser/as-e2e/intel/api-surface")
476 assert r.status_code == 200
477 # file part
478 assert "src/billing.py" in r.text
479 # name part
480 assert "compute_total" in r.text
481
482 @pytest.mark.asyncio
483 async def test_T19_dashboard_card_links_to_api_surface_page(
484 self, client: AsyncClient, as_repo: MusehubRepo
485 ) -> None:
486 """Intel dashboard must include a link to /intel/api-surface."""
487 r = await client.get("/asuser/as-e2e/intel")
488 assert r.status_code == 200
489 assert b"/intel/api-surface" in r.content
490
491
492 # ─────────────────────────────────────────────────────────────────────────────
493 # Layer T5 — State integrity
494 # ─────────────────────────────────────────────────────────────────────────────
495
496 class TestStateIntegrity:
497
498 @pytest.mark.asyncio
499 async def test_T20_double_upsert_produces_one_row(
500 self, db_session: AsyncSession
501 ) -> None:
502 """Upserting the same address twice must not create duplicate rows."""
503 repo = await create_repo(db_session, owner="asuser", slug="t20-dup")
504 rid = str(repo.repo_id)
505 addr = "src/a.py::fn"
506
507 for _ in range(2):
508 await _insert_as_row(db_session, rid, addr, kind="function")
509 await db_session.commit()
510
511 rows = (await db_session.execute(
512 sa.select(MusehubIntelApiSurface).where(
513 MusehubIntelApiSurface.repo_id == rid
514 )
515 )).scalars().all()
516 assert len(rows) == 1, f"Expected 1 row, got {len(rows)} — upsert created duplicates"
517
518 @pytest.mark.asyncio
519 async def test_T21_second_upsert_overwrites_kind(
520 self, db_session: AsyncSession
521 ) -> None:
522 """A second upsert with a different kind must overwrite the first."""
523 repo = await create_repo(db_session, owner="asuser", slug="t21-overwrite")
524 rid = str(repo.repo_id)
525 addr = "src/a.py::Foo"
526
527 await _insert_as_row(db_session, rid, addr, kind="class")
528 await _insert_as_row(db_session, rid, addr, kind="function")
529 await db_session.commit()
530
531 row = await db_session.scalar(
532 sa.select(MusehubIntelApiSurface).where(
533 MusehubIntelApiSurface.repo_id == rid,
534 MusehubIntelApiSurface.address == addr,
535 )
536 )
537 assert row is not None
538 assert row.kind == "function", (
539 f"Expected kind='function' after second upsert, got '{row.kind}'"
540 )
541
542 @pytest.mark.asyncio
543 async def test_T22_cross_repo_isolation(
544 self, db_session: AsyncSession
545 ) -> None:
546 """Symbols from repo A must not appear under repo B's page URL."""
547 repo_a = await create_repo(db_session, owner="asuser", slug="t22-repo-a")
548 repo_b = await create_repo(db_session, owner="asuser", slug="t22-repo-b")
549
550 await _insert_as_row(db_session, str(repo_a.repo_id),
551 "src/secret.py::private_fn", kind="function")
552 await db_session.commit()
553
554 rows_b = (await db_session.execute(
555 sa.select(MusehubIntelApiSurface).where(
556 MusehubIntelApiSurface.repo_id == str(repo_b.repo_id)
557 )
558 )).scalars().all()
559 assert not rows_b, "Repo B must not see Repo A's api_surface symbols"
560
561
562 # ─────────────────────────────────────────────────────────────────────────────
563 # Layer T6 — Performance
564 # ─────────────────────────────────────────────────────────────────────────────
565
566 class TestPerformance:
567
568 @pytest.mark.asyncio
569 async def test_T23_route_responds_under_200ms_for_5k_symbols(
570 self, client: AsyncClient, db_session: AsyncSession
571 ) -> None:
572 """Route must respond in < 200ms for a repo with 5,000 symbol rows."""
573 repo = await create_repo(db_session, owner="asuser", slug="t23-perf")
574 rid = str(repo.repo_id)
575
576 chunk_size = 1000
577 kinds = ["function", "async_function", "class", "method", "async_method"]
578 for start in range(0, 5_000, chunk_size):
579 rows = [
580 {
581 "repo_id": rid,
582 "address": f"src/file{i}.py::sym_{i}",
583 "kind": kinds[i % len(kinds)],
584 "signature_id": None,
585 "visibility": "public",
586 "ref": _REF,
587 }
588 for i in range(start, start + chunk_size)
589 ]
590 await db_session.execute(
591 pg_insert(MusehubIntelApiSurface)
592 .values(rows)
593 .on_conflict_do_nothing()
594 )
595 await db_session.commit()
596
597 t0 = time.monotonic()
598 r = await client.get("/asuser/t23-perf/intel/api-surface")
599 elapsed = time.monotonic() - t0
600
601 assert r.status_code == 200
602 assert elapsed < 0.2, f"Route took {elapsed:.3f}s for 5k symbols (limit: 0.2s)"
603
604 @pytest.mark.asyncio
605 async def test_T24_db_query_uses_repo_index(
606 self, db_session: AsyncSession
607 ) -> None:
608 """SELECT on musehub_intel_api_surface must use ix_intel_api_surface_repo index."""
609 explain = await db_session.execute(
610 sa.text(
611 "EXPLAIN SELECT * FROM musehub_intel_api_surface WHERE repo_id = 'x'"
612 )
613 )
614 plan = " ".join(row[0] for row in explain.all())
615 assert "ix_intel_api_surface_repo" in plan or "Index" in plan, (
616 f"Query plan does not use ix_intel_api_surface_repo:\n{plan}"
617 )
618
619 @pytest.mark.asyncio
620 async def test_T25_batch_upsert_1000_rows_under_500ms(
621 self, db_session: AsyncSession
622 ) -> None:
623 """Direct batch upsert of 1,000 rows must complete in < 500ms wall time."""
624 repo = await create_repo(db_session, owner="asuser", slug="t25-batch")
625 rid = str(repo.repo_id)
626 rows = [
627 {
628 "repo_id": rid,
629 "address": f"src/f{i}.py::fn",
630 "kind": "function",
631 "signature_id": None,
632 "visibility": "public",
633 "ref": _REF,
634 }
635 for i in range(1000)
636 ]
637 t0 = time.monotonic()
638 await db_session.execute(
639 pg_insert(MusehubIntelApiSurface)
640 .values(rows)
641 .on_conflict_do_nothing()
642 )
643 await db_session.commit()
644 elapsed = time.monotonic() - t0
645 assert elapsed < 0.5, f"1000-row batch took {elapsed:.3f}s (limit: 0.5s)"
646
647
648 # ─────────────────────────────────────────────────────────────────────────────
649 # Layer T7 — Security
650 # ─────────────────────────────────────────────────────────────────────────────
651
652 class TestSecurity:
653
654 @pytest.mark.asyncio
655 async def test_T26_xss_in_address_is_escaped(
656 self, client: AsyncClient, db_session: AsyncSession
657 ) -> None:
658 """XSS payload in address must be HTML-escaped in the response."""
659 repo = await create_repo(db_session, owner="asuser", slug="t26-xss")
660 rid = str(repo.repo_id)
661 xss = "<script>alert(1)</script>"
662 await _insert_as_row(db_session, rid, f"src/x.py::{xss[:40]}")
663 await db_session.commit()
664
665 r = await client.get("/asuser/t26-xss/intel/api-surface")
666 assert r.status_code == 200
667 assert "<script>alert" not in r.text, "XSS in address not escaped by Jinja2"
668
669 @pytest.mark.asyncio
670 async def test_T27_xss_in_kind_field_is_escaped(
671 self, client: AsyncClient, db_session: AsyncSession
672 ) -> None:
673 """XSS payload stored in kind must be HTML-escaped in the response."""
674 repo = await create_repo(db_session, owner="asuser", slug="t27-xss-kind")
675 rid = str(repo.repo_id)
676 await _insert_as_row(db_session, rid, "src/x.py::fn",
677 kind='<img src=x onerror=alert(1)>')
678 await db_session.commit()
679
680 r = await client.get("/asuser/t27-xss-kind/intel/api-surface")
681 assert r.status_code == 200
682 assert "<img src=x onerror" not in r.text, "XSS in kind not escaped"
683
684 @pytest.mark.asyncio
685 async def test_T28_sql_injection_in_kind_param_safe(
686 self, client: AsyncClient, as_repo: MusehubRepo
687 ) -> None:
688 """SQL injection string in ?kind= must be safely coerced, no 500."""
689 r = await client.get(
690 "/asuser/as-e2e/intel/api-surface?kind=function%27%20OR%20%271%27%3D%271"
691 )
692 assert r.status_code == 200, f"Expected 200 after SQL injection attempt, got {r.status_code}"
693
694 @pytest.mark.asyncio
695 async def test_T29_top_zero_coerced_to_default(
696 self, client: AsyncClient, as_repo: MusehubRepo
697 ) -> None:
698 """?top=0 must not issue an empty-LIMIT query; page returns 200."""
699 r = await client.get("/asuser/as-e2e/intel/api-surface?top=0")
700 # FastAPI will validate int but 0 is a valid int — route must coerce it
701 assert r.status_code in (200, 422), (
702 f"?top=0 returned unexpected status {r.status_code}"
703 )
704
705 @pytest.mark.asyncio
706 async def test_T30_private_repo_returns_403_or_404_unauthenticated(
707 self, client: AsyncClient
708 ) -> None:
709 """A non-existent repo path must not return 200 or 500."""
710 r = await client.get("/nobody/no-such-repo/intel/api-surface")
711 assert r.status_code in (403, 404)
712
713
714 # ---------------------------------------------------------------------------
715 # Internal helpers
716 # ---------------------------------------------------------------------------
717
718 def _mock_process(stdout: str, returncode: int = 0) -> AsyncMock:
719 proc = AsyncMock()
720 proc.returncode = returncode
721 proc.communicate = AsyncMock(return_value=(stdout.encode(), b""))
722 return proc
File History 2 commits
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 102 days ago
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff fix(tests): update test suite to match current implementation Sonnet 4.6 patch 123 days ago