gabriel / musehub public
test_intel_api_surface.py python
729 lines 30.1 KB
Raw
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day 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, MusehubObject, 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 # Seed MusehubObject rows so session.get(MusehubObject, oid) returns a row,
197 # allowing the provider to proceed to _read_obj_bytes → parse_symbols.
198 for path, oid in manifest.items():
199 await session.execute(
200 pg_insert(MusehubObject)
201 .values(
202 object_id=oid,
203 path=path,
204 size_bytes=16,
205 storage_uri=f"mem://{oid}",
206 )
207 .on_conflict_do_nothing(index_elements=["object_id"])
208 )
209 await session.commit()
210 return snap_id
211
212
213 def _fake_tree(n: int, prefix: str = "fn") -> JSONObject:
214 """Return a SymbolTree dict with *n* public function symbols."""
215 return {
216 f"src/file.py::{prefix}_{i}": {
217 "kind": "function",
218 "name": f"{prefix}_{i}",
219 "qualified_name": f"{prefix}_{i}",
220 "content_id": long_id("a" * 64),
221 "body_hash": long_id("b" * 64),
222 "signature_id": long_id("c" * 64),
223 "metadata_id": "",
224 "canonical_key": f"src/file.py##function#{prefix}_{i}#1",
225 "lineno": i + 1,
226 "end_lineno": i + 2,
227 }
228 for i in range(n)
229 }
230
231
232 class TestProviderBatch:
233
234 @pytest.mark.asyncio
235 async def test_T04_provider_issues_one_sql_per_chunk(
236 self, db_session: AsyncSession
237 ) -> None:
238 """ApiSurfaceProvider must batch-upsert, not execute one statement per symbol."""
239 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
240
241 repo = await create_repo(db_session, owner="asuser", slug="t04-batch")
242 rid = str(repo.repo_id)
243 await _seed_snapshot(db_session, rid, {"src/file.py": long_id("e" * 64)})
244
245 execute_calls: list[sa.Executable] = []
246 original_execute = db_session.execute
247
248 async def counting_execute(stmt: sa.Executable, *args: typing.Any, **kwargs: typing.Any) -> CursorResult[typing.Any]:
249 execute_calls.append(stmt)
250 return await original_execute(stmt, *args, **kwargs)
251
252 with (
253 patch("musehub.storage.backends.read_object_bytes",
254 new=AsyncMock(return_value=b"# placeholder")),
255 patch("musehub.services.musehub_intel_providers.parse_symbols",
256 return_value=_fake_tree(50)),
257 ):
258 db_session.execute = counting_execute # type: ignore[method-assign]
259 await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
260 db_session, rid, _REF,
261 {"owner": repo.owner, "slug": repo.slug},
262 )
263 db_session.execute = original_execute # type: ignore[method-assign]
264
265 # 50 symbols fit in one chunk — expect exactly 1 INSERT execute
266 insert_calls = [
267 c for c in execute_calls
268 if "insert" in str(type(c).__name__).lower() or "insert" in str(c).lower()
269 ]
270 assert len(insert_calls) == 1, (
271 f"Expected 1 batch upsert for 50 symbols, got {len(insert_calls)}"
272 )
273
274 @pytest.mark.asyncio
275 async def test_T05_provider_uses_ceil_n_over_1000_sql_calls_for_2500_symbols(
276 self, db_session: AsyncSession
277 ) -> None:
278 """2,500 symbols → exactly 3 INSERT statements (ceil(2500/1000) = 3)."""
279 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
280
281 repo = await create_repo(db_session, owner="asuser", slug="t05-chunks")
282 rid = str(repo.repo_id)
283 await _seed_snapshot(db_session, rid, {"src/big.py": long_id("f" * 64)})
284
285 execute_calls: list[sa.Executable] = []
286 original_execute = db_session.execute
287
288 async def counting_execute(stmt: sa.Executable, *args: typing.Any, **kwargs: typing.Any) -> CursorResult[typing.Any]:
289 execute_calls.append(stmt)
290 return await original_execute(stmt, *args, **kwargs)
291
292 with (
293 patch("musehub.storage.backends.read_object_bytes",
294 new=AsyncMock(return_value=b"# placeholder")),
295 patch("musehub.services.musehub_intel_providers.parse_symbols",
296 return_value=_fake_tree(2500)),
297 ):
298 db_session.execute = counting_execute # type: ignore[method-assign]
299 result = await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
300 db_session, rid, _REF,
301 {"owner": repo.owner, "slug": repo.slug},
302 )
303 db_session.execute = original_execute # type: ignore[method-assign]
304
305 insert_calls = [
306 c for c in execute_calls
307 if "insert" in str(type(c).__name__).lower() or "insert" in str(c).lower()
308 ]
309 assert len(insert_calls) == 3, (
310 f"2500 symbols should produce 3 INSERT chunks, got {len(insert_calls)}"
311 )
312 assert result == [("intel.code.api_surface", {"count": 2500})]
313
314 @pytest.mark.asyncio
315 async def test_T06_empty_symbols_returns_empty_list(
316 self, db_session: AsyncSession
317 ) -> None:
318 """Provider must return [] and issue no INSERTs when parse_symbols yields nothing."""
319 from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY
320
321 repo = await create_repo(db_session, owner="asuser", slug="t06-empty")
322 rid = str(repo.repo_id)
323 await _seed_snapshot(db_session, rid, {"src/empty.py": long_id("a" * 64)})
324
325 execute_calls: list[sa.Executable] = []
326 original_execute = db_session.execute
327
328 async def counting_execute(stmt: sa.Executable, *args: typing.Any, **kwargs: typing.Any) -> CursorResult[typing.Any]:
329 execute_calls.append(stmt)
330 return await original_execute(stmt, *args, **kwargs)
331
332 mock_backend = AsyncMock()
333 mock_backend.get = AsyncMock(return_value=b"# no public symbols")
334
335 with (
336 patch("musehub.services.musehub_intel_providers.get_backend",
337 return_value=mock_backend),
338 patch("musehub.services.musehub_intel_providers.parse_symbols",
339 return_value={}),
340 ):
341 db_session.execute = counting_execute # type: ignore[method-assign]
342 result = await _PROVIDER_REGISTRY["intel.code.api_surface"].compute(
343 db_session, rid, _REF,
344 {"owner": repo.owner, "slug": repo.slug},
345 )
346 db_session.execute = original_execute # type: ignore[method-assign]
347
348 assert result == [], "Empty symbols list must return []"
349 insert_calls = [c for c in execute_calls if "insert" in str(c).lower()]
350 assert len(insert_calls) == 0, "No DB writes expected for empty symbol list"
351
352
353 # ─────────────────────────────────────────────────────────────────────────────
354 # Layer T3 — Route (unit / integration)
355 # ─────────────────────────────────────────────────────────────────────────────
356
357 class TestRoute:
358
359 @pytest.mark.asyncio
360 async def test_T07_returns_200_with_empty_repo(
361 self, client: AsyncClient, db_session: AsyncSession
362 ) -> None:
363 """Route must return 200 even when musehub_intel_api_surface has no rows."""
364 await create_repo(db_session, owner="asuser", slug="t07-empty")
365 await db_session.commit()
366 r = await client.get("/asuser/t07-empty/intel/api-surface")
367 assert r.status_code == 200
368
369 @pytest.mark.asyncio
370 async def test_T08_returns_200_with_data(
371 self, client: AsyncClient, as_repo: MusehubRepo
372 ) -> None:
373 """Route returns 200 when rows exist."""
374 r = await client.get("/asuser/as-e2e/intel/api-surface")
375 assert r.status_code == 200
376
377 @pytest.mark.asyncio
378 async def test_T09_kind_filter_function_only(
379 self, client: AsyncClient, as_repo: MusehubRepo
380 ) -> None:
381 """?kind=function returns only function symbols, not class or method."""
382 r = await client.get("/asuser/as-e2e/intel/api-surface?kind=function")
383 assert r.status_code == 200
384 assert "compute_total" in r.text
385 assert "UserRecord.save" not in r.text
386 assert "UserRecord" not in r.text or "compute_total" in r.text
387
388 @pytest.mark.asyncio
389 async def test_T10_kind_filter_class_only(
390 self, client: AsyncClient, as_repo: MusehubRepo
391 ) -> None:
392 """?kind=class returns only class symbols."""
393 r = await client.get("/asuser/as-e2e/intel/api-surface?kind=class")
394 assert r.status_code == 200
395 assert "UserRecord" in r.text
396 assert "compute_total" not in r.text
397
398 @pytest.mark.asyncio
399 async def test_T11_kind_filter_async_function(
400 self, client: AsyncClient, as_repo: MusehubRepo
401 ) -> None:
402 """?kind=async_function returns only async_function symbols."""
403 r = await client.get("/asuser/as-e2e/intel/api-surface?kind=async_function")
404 assert r.status_code == 200
405 assert "async_fetch" in r.text
406 assert "compute_total" not in r.text
407
408 @pytest.mark.asyncio
409 async def test_T12_unknown_kind_coerced_to_all(
410 self, client: AsyncClient, as_repo: MusehubRepo
411 ) -> None:
412 """?kind=garbage must return 200 (treated as no filter), not 400/500."""
413 r = await client.get("/asuser/as-e2e/intel/api-surface?kind=garbage")
414 assert r.status_code == 200
415
416 @pytest.mark.asyncio
417 async def test_T13_top_param_limits_results(
418 self, client: AsyncClient, db_session: AsyncSession
419 ) -> None:
420 """?top=20 returns at most 20 symbols even when 25 exist."""
421 repo = await create_repo(db_session, owner="asuser", slug="t13-top")
422 rid = str(repo.repo_id)
423 for i in range(25):
424 await _insert_as_row(db_session, rid,
425 f"src/f{i}.py::fn_{i}", kind="function")
426 await db_session.commit()
427
428 r = await client.get("/asuser/t13-top/intel/api-surface?top=20")
429 assert r.status_code == 200
430 count = sum(1 for i in range(25) if f"src/f{i}.py::fn_{i}" in r.text)
431 assert count <= 20, f"Expected ≤20 results for ?top=20, got {count}"
432
433 @pytest.mark.asyncio
434 async def test_T14_top_invalid_string_returns_422(
435 self, client: AsyncClient, as_repo: MusehubRepo
436 ) -> None:
437 """?top=notanumber must be rejected with 422 (FastAPI type validation)."""
438 r = await client.get("/asuser/as-e2e/intel/api-surface?top=notanumber")
439 assert r.status_code == 422
440
441 @pytest.mark.asyncio
442 async def test_T15_unknown_repo_returns_404(
443 self, client: AsyncClient
444 ) -> None:
445 """Non-existent repo path must return 404, not 200 or 500."""
446 r = await client.get("/nobody/no-such-repo/intel/api-surface")
447 assert r.status_code in (403, 404)
448
449
450 # ─────────────────────────────────────────────────────────────────────────────
451 # Layer T4 — E2E (HTML body assertions)
452 # ─────────────────────────────────────────────────────────────────────────────
453
454 class TestE2E:
455
456 @pytest.mark.asyncio
457 async def test_T16_total_count_chip_shows_correct_value(
458 self, client: AsyncClient, as_repo: MusehubRepo
459 ) -> None:
460 """Stat chip for Total must reflect the DB row count (5 symbols seeded)."""
461 r = await client.get("/asuser/as-e2e/intel/api-surface")
462 assert r.status_code == 200
463 # 5 symbols seeded in fixture; total chip must contain "5"
464 assert "5" in r.text
465
466 @pytest.mark.asyncio
467 async def test_T17_kind_breakdown_chips_present(
468 self, client: AsyncClient, as_repo: MusehubRepo
469 ) -> None:
470 """Kind breakdown stat chips must appear for all five kinds."""
471 r = await client.get("/asuser/as-e2e/intel/api-surface")
472 assert r.status_code == 200
473 body = r.text.lower()
474 for kind_label in ("function", "class", "method"):
475 assert kind_label in body, f"Kind label '{kind_label}' missing from page"
476
477 @pytest.mark.asyncio
478 async def test_T18_symbol_address_split_rendered(
479 self, client: AsyncClient, as_repo: MusehubRepo
480 ) -> None:
481 """Symbol file and name parts must both appear in the HTML."""
482 r = await client.get("/asuser/as-e2e/intel/api-surface")
483 assert r.status_code == 200
484 # file part
485 assert "src/billing.py" in r.text
486 # name part
487 assert "compute_total" in r.text
488
489 @pytest.mark.asyncio
490 async def test_T19_dashboard_card_links_to_api_surface_page(
491 self, client: AsyncClient, as_repo: MusehubRepo
492 ) -> None:
493 """Intel dashboard must include a link to /intel/api-surface."""
494 r = await client.get("/asuser/as-e2e/intel")
495 assert r.status_code == 200
496 assert b"/intel/api-surface" in r.content
497
498
499 # ─────────────────────────────────────────────────────────────────────────────
500 # Layer T5 — State integrity
501 # ─────────────────────────────────────────────────────────────────────────────
502
503 class TestStateIntegrity:
504
505 @pytest.mark.asyncio
506 async def test_T20_double_upsert_produces_one_row(
507 self, db_session: AsyncSession
508 ) -> None:
509 """Upserting the same address twice must not create duplicate rows."""
510 repo = await create_repo(db_session, owner="asuser", slug="t20-dup")
511 rid = str(repo.repo_id)
512 addr = "src/a.py::fn"
513
514 for _ in range(2):
515 await _insert_as_row(db_session, rid, addr, kind="function")
516 await db_session.commit()
517
518 rows = (await db_session.execute(
519 sa.select(MusehubIntelApiSurface).where(
520 MusehubIntelApiSurface.repo_id == rid
521 )
522 )).scalars().all()
523 assert len(rows) == 1, f"Expected 1 row, got {len(rows)} — upsert created duplicates"
524
525 @pytest.mark.asyncio
526 async def test_T21_second_upsert_overwrites_kind(
527 self, db_session: AsyncSession
528 ) -> None:
529 """A second upsert with a different kind must overwrite the first."""
530 repo = await create_repo(db_session, owner="asuser", slug="t21-overwrite")
531 rid = str(repo.repo_id)
532 addr = "src/a.py::Foo"
533
534 await _insert_as_row(db_session, rid, addr, kind="class")
535 await _insert_as_row(db_session, rid, addr, kind="function")
536 await db_session.commit()
537
538 row = await db_session.scalar(
539 sa.select(MusehubIntelApiSurface).where(
540 MusehubIntelApiSurface.repo_id == rid,
541 MusehubIntelApiSurface.address == addr,
542 )
543 )
544 assert row is not None
545 assert row.kind == "function", (
546 f"Expected kind='function' after second upsert, got '{row.kind}'"
547 )
548
549 @pytest.mark.asyncio
550 async def test_T22_cross_repo_isolation(
551 self, db_session: AsyncSession
552 ) -> None:
553 """Symbols from repo A must not appear under repo B's page URL."""
554 repo_a = await create_repo(db_session, owner="asuser", slug="t22-repo-a")
555 repo_b = await create_repo(db_session, owner="asuser", slug="t22-repo-b")
556
557 await _insert_as_row(db_session, str(repo_a.repo_id),
558 "src/secret.py::private_fn", kind="function")
559 await db_session.commit()
560
561 rows_b = (await db_session.execute(
562 sa.select(MusehubIntelApiSurface).where(
563 MusehubIntelApiSurface.repo_id == str(repo_b.repo_id)
564 )
565 )).scalars().all()
566 assert not rows_b, "Repo B must not see Repo A's api_surface symbols"
567
568
569 # ─────────────────────────────────────────────────────────────────────────────
570 # Layer T6 — Performance
571 # ─────────────────────────────────────────────────────────────────────────────
572
573 class TestPerformance:
574
575 @pytest.mark.asyncio
576 async def test_T23_route_responds_under_200ms_for_5k_symbols(
577 self, client: AsyncClient, db_session: AsyncSession
578 ) -> None:
579 """Route must respond in < 200ms for a repo with 5,000 symbol rows."""
580 repo = await create_repo(db_session, owner="asuser", slug="t23-perf")
581 rid = str(repo.repo_id)
582
583 chunk_size = 1000
584 kinds = ["function", "async_function", "class", "method", "async_method"]
585 for start in range(0, 5_000, chunk_size):
586 rows = [
587 {
588 "repo_id": rid,
589 "address": f"src/file{i}.py::sym_{i}",
590 "kind": kinds[i % len(kinds)],
591 "signature_id": None,
592 "visibility": "public",
593 "ref": _REF,
594 }
595 for i in range(start, start + chunk_size)
596 ]
597 await db_session.execute(
598 pg_insert(MusehubIntelApiSurface)
599 .values(rows)
600 .on_conflict_do_nothing()
601 )
602 await db_session.commit()
603
604 t0 = time.monotonic()
605 r = await client.get("/asuser/t23-perf/intel/api-surface")
606 elapsed = time.monotonic() - t0
607
608 assert r.status_code == 200
609 assert elapsed < 0.2, f"Route took {elapsed:.3f}s for 5k symbols (limit: 0.2s)"
610
611 @pytest.mark.asyncio
612 async def test_T24_db_query_uses_repo_index(
613 self, db_session: AsyncSession
614 ) -> None:
615 """SELECT on musehub_intel_api_surface must use ix_intel_api_surface_repo index."""
616 explain = await db_session.execute(
617 sa.text(
618 "EXPLAIN SELECT * FROM musehub_intel_api_surface WHERE repo_id = 'x'"
619 )
620 )
621 plan = " ".join(row[0] for row in explain.all())
622 assert "ix_intel_api_surface_repo" in plan or "Index" in plan, (
623 f"Query plan does not use ix_intel_api_surface_repo:\n{plan}"
624 )
625
626 @pytest.mark.asyncio
627 async def test_T25_batch_upsert_1000_rows_under_500ms(
628 self, db_session: AsyncSession
629 ) -> None:
630 """Direct batch upsert of 1,000 rows must complete in < 500ms wall time."""
631 repo = await create_repo(db_session, owner="asuser", slug="t25-batch")
632 rid = str(repo.repo_id)
633 rows = [
634 {
635 "repo_id": rid,
636 "address": f"src/f{i}.py::fn",
637 "kind": "function",
638 "signature_id": None,
639 "visibility": "public",
640 "ref": _REF,
641 }
642 for i in range(1000)
643 ]
644 t0 = time.monotonic()
645 await db_session.execute(
646 pg_insert(MusehubIntelApiSurface)
647 .values(rows)
648 .on_conflict_do_nothing()
649 )
650 await db_session.commit()
651 elapsed = time.monotonic() - t0
652 assert elapsed < 0.5, f"1000-row batch took {elapsed:.3f}s (limit: 0.5s)"
653
654
655 # ─────────────────────────────────────────────────────────────────────────────
656 # Layer T7 — Security
657 # ─────────────────────────────────────────────────────────────────────────────
658
659 class TestSecurity:
660
661 @pytest.mark.asyncio
662 async def test_T26_xss_in_address_is_escaped(
663 self, client: AsyncClient, db_session: AsyncSession
664 ) -> None:
665 """XSS payload in address must be HTML-escaped in the response."""
666 repo = await create_repo(db_session, owner="asuser", slug="t26-xss")
667 rid = str(repo.repo_id)
668 xss = "<script>alert(1)</script>"
669 await _insert_as_row(db_session, rid, f"src/x.py::{xss[:40]}")
670 await db_session.commit()
671
672 r = await client.get("/asuser/t26-xss/intel/api-surface")
673 assert r.status_code == 200
674 assert "<script>alert" not in r.text, "XSS in address not escaped by Jinja2"
675
676 @pytest.mark.asyncio
677 async def test_T27_xss_in_kind_field_is_escaped(
678 self, client: AsyncClient, db_session: AsyncSession
679 ) -> None:
680 """XSS payload stored in kind must be HTML-escaped in the response."""
681 repo = await create_repo(db_session, owner="asuser", slug="t27-xss-kind")
682 rid = str(repo.repo_id)
683 await _insert_as_row(db_session, rid, "src/x.py::fn",
684 kind='<img src=x onerror=alert(1)>')
685 await db_session.commit()
686
687 r = await client.get("/asuser/t27-xss-kind/intel/api-surface")
688 assert r.status_code == 200
689 assert "<img src=x onerror" not in r.text, "XSS in kind not escaped"
690
691 @pytest.mark.asyncio
692 async def test_T28_sql_injection_in_kind_param_safe(
693 self, client: AsyncClient, as_repo: MusehubRepo
694 ) -> None:
695 """SQL injection string in ?kind= must be safely coerced, no 500."""
696 r = await client.get(
697 "/asuser/as-e2e/intel/api-surface?kind=function%27%20OR%20%271%27%3D%271"
698 )
699 assert r.status_code == 200, f"Expected 200 after SQL injection attempt, got {r.status_code}"
700
701 @pytest.mark.asyncio
702 async def test_T29_top_zero_coerced_to_default(
703 self, client: AsyncClient, as_repo: MusehubRepo
704 ) -> None:
705 """?top=0 must not issue an empty-LIMIT query; page returns 200."""
706 r = await client.get("/asuser/as-e2e/intel/api-surface?top=0")
707 # FastAPI will validate int but 0 is a valid int — route must coerce it
708 assert r.status_code in (200, 422), (
709 f"?top=0 returned unexpected status {r.status_code}"
710 )
711
712 @pytest.mark.asyncio
713 async def test_T30_private_repo_returns_403_or_404_unauthenticated(
714 self, client: AsyncClient
715 ) -> None:
716 """A non-existent repo path must not return 200 or 500."""
717 r = await client.get("/nobody/no-such-repo/intel/api-surface")
718 assert r.status_code in (403, 404)
719
720
721 # ---------------------------------------------------------------------------
722 # Internal helpers
723 # ---------------------------------------------------------------------------
724
725 def _mock_process(stdout: str, returncode: int = 0) -> AsyncMock:
726 proc = AsyncMock()
727 proc.returncode = returncode
728 proc.communicate = AsyncMock(return_value=(stdout.encode(), b""))
729 return proc
File History 3 commits
sha256:94ef169c149a452bff7c604ded8b280b19bd477c2dabcb56972780b0b784c7aa Merge 'fix/assignee-sigil-inline' into 'dev' — proposal: As… Human 1 day ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 10 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 12 days ago