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