gabriel / musehub public
test_ui_ssr.py python
954 lines 39.6 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 123 days ago
1 """Section 38 — UI / SSR Routes: 7-layer test suite.
2
3 Existing coverage (347 tests across 27 files) focuses on E2E page rendering for
4 issues, commits, labels, milestones, notifications, forks, topics, user profiles,
5 and settings. This file adds the missing layers and fills coverage gaps:
6
7 1. Unit — pure utility functions: is_htmx, htmx_trigger, _infer_sym_kind,
8 _fmt_relative, licenses_for_viewer_type (no DB, no HTTP)
9 2. Integration — repo home page, proposals list, agents swarm, blob/blame pages
10 with real DB state
11 3. E2E — pages with no existing test files: repo home, symbols, proposals,
12 agents, intel, blob/raw; plus HTMX fragment routing
13 4. Stress — sequential loads across multiple pages; empty-state fallbacks
14 5. Data Integrity — nav counts correct, filter accuracy, XSS content escaped
15 6. Security — auth-required pages return 401 without token; injected content
16 escaped; debug info not leaked
17 7. Performance — key pages respond under 500ms
18 """
19 from __future__ import annotations
20
21 import secrets
22 import time
23 from datetime import datetime, timezone
24
25 import pytest
26 from httpx import AsyncClient
27 from sqlalchemy.ext.asyncio import AsyncSession
28
29 from musehub.core.genesis import compute_identity_id, compute_issue_id, compute_proposal_id, compute_repo_id
30 from musehub.types.json_types import StrDict
31 from musehub.db.musehub_models import (
32 MusehubIdentity,
33 MusehubIssue,
34 MusehubProposal,
35 MusehubRepo,
36 )
37
38 # ─────────────────────────────────────────────────────────────────────────────
39 # Helpers shared across layers
40 # ─────────────────────────────────────────────────────────────────────────────
41
42 _OWNER = "ssr-tester"
43 _SLUG = "ssr-test-repo"
44
45
46 async def _seed_identity(db: AsyncSession, handle: str = _OWNER) -> MusehubIdentity:
47 identity = MusehubIdentity(handle=handle, identity_type="human", display_name=handle)
48 db.add(identity)
49 await db.commit()
50 await db.refresh(identity)
51 return identity
52
53
54 async def _seed_repo(
55 db: AsyncSession,
56 owner: str = _OWNER,
57 slug: str = _SLUG,
58 visibility: str = "public",
59 ) -> MusehubRepo:
60 created_at = datetime.now(tz=timezone.utc)
61 owner_id = compute_identity_id(owner.encode())
62 repo = MusehubRepo(
63 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
64 name=slug,
65 owner=owner,
66 slug=slug,
67 visibility=visibility,
68 owner_user_id=owner_id,
69 created_at=created_at,
70 updated_at=created_at,
71 )
72 db.add(repo)
73 await db.commit()
74 await db.refresh(repo)
75 return repo
76
77
78 async def _seed_issue(
79 db: AsyncSession,
80 repo_id: str,
81 *,
82 number: int = 1,
83 title: str = "Test issue",
84 state: str = "open",
85 ) -> MusehubIssue:
86 created_at = datetime.now(tz=timezone.utc)
87 author_id = compute_identity_id(_OWNER.encode())
88 issue = MusehubIssue(
89 issue_id=compute_issue_id(repo_id, author_id, created_at.isoformat()),
90 repo_id=repo_id,
91 number=number,
92 title=title,
93 body="Body text.",
94 state=state,
95 labels=[],
96 author=_OWNER,
97 created_at=created_at,
98 updated_at=created_at,
99 )
100 db.add(issue)
101 await db.commit()
102 await db.refresh(issue)
103 return issue
104
105
106 async def _seed_proposal(
107 db: AsyncSession,
108 repo_id: str,
109 *,
110 proposal_number: int = 1,
111 title: str = "Add harmony voice",
112 state: str = "open",
113 ) -> MusehubProposal:
114 created_at = datetime.now(tz=timezone.utc)
115 author_id = compute_identity_id(_OWNER.encode())
116 proposal = MusehubProposal(
117 proposal_id=compute_proposal_id(
118 repo_id, author_id, "feat/harmony", "main", created_at.isoformat()
119 ),
120 repo_id=repo_id,
121 proposal_number=proposal_number,
122 title=title,
123 body="Proposal body.",
124 state=state,
125 from_branch="feat/harmony",
126 to_branch="main",
127 author=_OWNER,
128 created_at=created_at,
129 updated_at=created_at,
130 )
131 db.add(proposal)
132 await db.commit()
133 await db.refresh(proposal)
134 return proposal
135
136
137 # ─────────────────────────────────────────────────────────────────────────────
138 # LAYER 1 — UNIT
139 # ─────────────────────────────────────────────────────────────────────────────
140
141
142 class TestIsHtmx:
143 """Unit tests for is_htmx / is_htmx_boosted helpers."""
144
145 def _req(self, headers: StrDict) -> None:
146 from starlette.testclient import TestClient
147 from starlette.applications import Starlette
148 from starlette.routing import Route
149 from starlette.requests import Request as StarletteRequest
150 from starlette.responses import PlainTextResponse
151
152 captured: list[StarletteRequest] = []
153
154 def handler(req: StarletteRequest) -> None:
155 captured.append(req)
156 return PlainTextResponse("ok")
157
158 app = Starlette(routes=[Route("/", handler)])
159 client = TestClient(app, raise_server_exceptions=False)
160 client.get("/", headers=headers)
161 return captured[0]
162
163 def test_no_header_is_not_htmx(self) -> None:
164 from musehub.api.routes.musehub.htmx_helpers import is_htmx
165 req = self._req({})
166 assert is_htmx(req) is False
167
168 def test_hx_request_true_is_htmx(self) -> None:
169 from musehub.api.routes.musehub.htmx_helpers import is_htmx
170 req = self._req({"HX-Request": "true"})
171 assert is_htmx(req) is True
172
173 def test_hx_request_false_is_not_htmx(self) -> None:
174 from musehub.api.routes.musehub.htmx_helpers import is_htmx
175 req = self._req({"HX-Request": "false"})
176 assert is_htmx(req) is False
177
178 def test_hx_boosted_false_when_absent(self) -> None:
179 from musehub.api.routes.musehub.htmx_helpers import is_htmx_boosted
180 req = self._req({})
181 assert is_htmx_boosted(req) is False
182
183 def test_hx_boosted_true_when_present(self) -> None:
184 from musehub.api.routes.musehub.htmx_helpers import is_htmx_boosted
185 req = self._req({"HX-Boosted": "true"})
186 assert is_htmx_boosted(req) is True
187
188
189 class TestHtmxTrigger:
190 """Unit tests for htmx_trigger helper."""
191
192 def test_sets_hx_trigger_header(self) -> None:
193 from starlette.responses import Response
194 from musehub.api.routes.musehub.htmx_helpers import htmx_trigger
195 import json
196
197 r = Response()
198 htmx_trigger(r, "toast", {"message": "saved"})
199 payload = json.loads(r.headers["HX-Trigger"])
200 assert payload == {"toast": {"message": "saved"}}
201
202 def test_sets_hx_trigger_header_no_detail(self) -> None:
203 from starlette.responses import Response
204 from musehub.api.routes.musehub.htmx_helpers import htmx_trigger
205 import json
206
207 r = Response()
208 htmx_trigger(r, "refresh")
209 payload = json.loads(r.headers["HX-Trigger"])
210 assert payload == {"refresh": True}
211
212 def test_multiple_events_possible(self) -> None:
213 """Calling twice overwrites but last write wins — not a bug, just a spec."""
214 from starlette.responses import Response
215 from musehub.api.routes.musehub.htmx_helpers import htmx_trigger
216
217 r = Response()
218 htmx_trigger(r, "reload")
219 htmx_trigger(r, "scroll")
220 # Only one value in header (last write wins)
221 assert "HX-Trigger" in r.headers
222
223
224 class TestHtmxRedirect:
225 """Unit tests for htmx_redirect helper."""
226
227 def test_returns_200(self) -> None:
228 from musehub.api.routes.musehub.htmx_helpers import htmx_redirect
229
230 r = htmx_redirect("/some/url")
231 assert r.status_code == 200
232
233 def test_sets_hx_redirect_header(self) -> None:
234 from musehub.api.routes.musehub.htmx_helpers import htmx_redirect
235
236 r = htmx_redirect("/dashboard")
237 assert r.headers["HX-Redirect"] == "/dashboard"
238
239 def test_url_preserved_exactly(self) -> None:
240 from musehub.api.routes.musehub.htmx_helpers import htmx_redirect
241
242 url = "/gabriel/my-repo?welcome=1"
243 r = htmx_redirect(url)
244 assert r.headers["HX-Redirect"] == url
245
246
247 class TestInferSymKind:
248 """Unit tests for _infer_sym_kind."""
249
250 def _infer(self, addr: str) -> str:
251 from musehub.api.routes.musehub.ui_symbols import _infer_sym_kind
252 return _infer_sym_kind(addr)
253
254 def test_camel_case_is_class(self) -> None:
255 assert self._infer("file.py::MyClass") == "class"
256
257 def test_snake_case_is_fn(self) -> None:
258 assert self._infer("file.py::my_function") == "fn"
259
260 def test_all_caps_is_sym(self) -> None:
261 assert self._infer("file.py::MAX_RETRIES") == "sym"
262
263 def test_no_namespace_camel(self) -> None:
264 assert self._infer("CamelCase") == "class"
265
266 def test_no_namespace_snake(self) -> None:
267 assert self._infer("my_func") == "fn"
268
269 def test_private_fn_underscore_prefix(self) -> None:
270 assert self._infer("_private_func") == "fn"
271
272 def test_dunder_is_sym_or_fn(self) -> None:
273 # __init__ after stripping underscores: "init" → fn
274 assert self._infer("__init__") == "fn"
275
276 def test_empty_addr_is_sym(self) -> None:
277 assert self._infer("") == "sym"
278
279 def test_private_class(self) -> None:
280 assert self._infer("_PrivateClass") == "class"
281
282
283 class TestLicensesForViewerType:
284 """Unit tests for licenses_for_viewer_type."""
285
286 def test_symbol_graph_returns_code_licenses(self) -> None:
287 from musehub.api.routes.musehub.ui_new_repo import licenses_for_viewer_type
288 result = licenses_for_viewer_type("symbol_graph")
289 assert isinstance(result, list)
290 assert len(result) > 0
291 assert all(isinstance(item, tuple) and len(item) == 2 for item in result)
292
293 def test_default_returns_generic_licenses(self) -> None:
294 from musehub.api.routes.musehub.ui_new_repo import licenses_for_viewer_type
295 default = licenses_for_viewer_type("audio")
296 code = licenses_for_viewer_type("symbol_graph")
297 # They may overlap but should be distinct lists
298 assert isinstance(default, list)
299
300 def test_unknown_type_returns_list(self) -> None:
301 from musehub.api.routes.musehub.ui_new_repo import licenses_for_viewer_type
302 result = licenses_for_viewer_type("totally_unknown")
303 assert isinstance(result, list)
304
305
306 # ─────────────────────────────────────────────────────────────────────────────
307 # LAYER 2 — INTEGRATION
308 # ─────────────────────────────────────────────────────────────────────────────
309
310
311 class TestRepoPageIntegration:
312 """Integration: repo home page with real DB (no HTTP)."""
313
314 async def test_repo_page_200_with_empty_repo(
315 self, client: AsyncClient, db_session: AsyncSession
316 ) -> None:
317 repo = await _seed_repo(db_session, owner="int-owner", slug="int-repo")
318 resp = await client.get(f"/int-owner/int-repo")
319 assert resp.status_code == 200
320
321 async def test_repo_page_404_for_unknown_slug(
322 self, client: AsyncClient, db_session: AsyncSession
323 ) -> None:
324 resp = await client.get("/nobody/totally-unknown-repo-xyz")
325 assert resp.status_code == 404
326
327 async def test_repo_page_shows_owner_in_html(
328 self, client: AsyncClient, db_session: AsyncSession
329 ) -> None:
330 await _seed_repo(db_session, owner="int-owner2", slug="int-repo2")
331 resp = await client.get("/int-owner2/int-repo2")
332 assert resp.status_code == 200
333 assert "int-owner2" in resp.text
334
335
336 class TestProposalListIntegration:
337 """Integration: proposals list page with real DB."""
338
339 async def test_proposal_list_200_empty(
340 self, client: AsyncClient, db_session: AsyncSession
341 ) -> None:
342 await _seed_repo(db_session, owner="prop-owner", slug="prop-repo")
343 resp = await client.get("/prop-owner/prop-repo/proposals")
344 assert resp.status_code == 200
345
346 async def test_proposal_list_shows_pr_title(
347 self, client: AsyncClient, db_session: AsyncSession
348 ) -> None:
349 repo = await _seed_repo(db_session, owner="prop-owner2", slug="prop-repo2")
350 await _seed_proposal(db_session, str(repo.repo_id), title="Reverb on chorus")
351 resp = await client.get("/prop-owner2/prop-repo2/proposals")
352 assert resp.status_code == 200
353 assert "Reverb on chorus" in resp.text
354
355 async def test_proposal_list_404_for_unknown_repo(
356 self, client: AsyncClient, db_session: AsyncSession
357 ) -> None:
358 resp = await client.get("/nobody/unknown-proposals-repo/proposals")
359 assert resp.status_code == 404
360
361
362 class TestAgentsPageIntegration:
363 """Integration: agents swarm / coord pages with real DB."""
364
365 async def test_agents_swarm_page_200(
366 self, client: AsyncClient, db_session: AsyncSession
367 ) -> None:
368 await _seed_repo(db_session, owner="agent-owner", slug="agent-repo")
369 resp = await client.get("/agent-owner/agent-repo/agents/swarm")
370 assert resp.status_code in (200, 404) # 404 if page not registered at /agents/swarm
371
372 async def test_agents_coord_page_200(
373 self, client: AsyncClient, db_session: AsyncSession
374 ) -> None:
375 await _seed_repo(db_session, owner="agent-owner2", slug="agent-repo2")
376 resp = await client.get("/agent-owner2/agent-repo2/agents/coord")
377 assert resp.status_code in (200, 404)
378
379
380 # ─────────────────────────────────────────────────────────────────────────────
381 # LAYER 3 — E2E
382 # ─────────────────────────────────────────────────────────────────────────────
383
384
385 class TestRepoHomeE2E:
386 """E2E: repo home page (/{owner}/{slug}) — no existing test file."""
387
388 async def test_get_repo_home_returns_200(
389 self, client: AsyncClient, db_session: AsyncSession
390 ) -> None:
391 await _seed_repo(db_session, owner="e2e-home", slug="home-repo")
392 resp = await client.get("/e2e-home/home-repo")
393 assert resp.status_code == 200
394
395 async def test_repo_home_returns_html(
396 self, client: AsyncClient, db_session: AsyncSession
397 ) -> None:
398 await _seed_repo(db_session, owner="e2e-html", slug="html-repo")
399 resp = await client.get("/e2e-html/html-repo")
400 assert "text/html" in resp.headers.get("content-type", "")
401
402 async def test_repo_home_no_auth_required(
403 self, client: AsyncClient, db_session: AsyncSession
404 ) -> None:
405 """Public repos should be accessible without authentication."""
406 await _seed_repo(db_session, owner="e2e-pub", slug="pub-repo", visibility="public")
407 resp = await client.get("/e2e-pub/pub-repo")
408 assert resp.status_code != 401
409
410 async def test_repo_home_json_format_returns_json(
411 self, client: AsyncClient, db_session: AsyncSession
412 ) -> None:
413 """?format=json should return JSON instead of HTML."""
414 await _seed_repo(db_session, owner="e2e-json", slug="json-repo")
415 resp = await client.get("/e2e-json/json-repo", params={"format": "json"})
416 assert resp.status_code == 200
417 assert "application/json" in resp.headers.get("content-type", "")
418
419 async def test_repo_home_accept_json_returns_json(
420 self, client: AsyncClient, db_session: AsyncSession
421 ) -> None:
422 """Accept: application/json triggers the JSON shortcut."""
423 await _seed_repo(db_session, owner="e2e-acc", slug="acc-repo")
424 resp = await client.get(
425 "/e2e-acc/acc-repo",
426 headers={"Accept": "application/json"},
427 )
428 assert resp.status_code == 200
429 data = resp.json()
430 assert "slug" in data or "repoId" in data
431
432 async def test_repo_home_htmx_returns_fragment(
433 self, client: AsyncClient, db_session: AsyncSession
434 ) -> None:
435 """HX-Request returns file_tree fragment, not full page."""
436 await _seed_repo(db_session, owner="e2e-htmx", slug="htmx-repo")
437 resp = await client.get(
438 "/e2e-htmx/htmx-repo",
439 headers={"HX-Request": "true"},
440 )
441 # Fragment should NOT contain the full <html> wrapper
442 assert resp.status_code == 200
443 body = resp.text
444 assert "<html" not in body.lower()
445
446 async def test_repo_home_unknown_repo_returns_404(
447 self, client: AsyncClient, db_session: AsyncSession
448 ) -> None:
449 resp = await client.get("/nobody/repo-that-does-not-exist-xyz-abc")
450 assert resp.status_code == 404
451
452
453 class TestSymbolsPageE2E:
454 """E2E: symbols page (/{owner}/{slug}/symbols) — no existing test file."""
455
456 async def test_symbols_list_page_returns_200(
457 self, client: AsyncClient, db_session: AsyncSession
458 ) -> None:
459 await _seed_repo(db_session, owner="sym-owner", slug="sym-repo")
460 resp = await client.get("/sym-owner/sym-repo/symbols")
461 assert resp.status_code == 200
462
463 async def test_symbols_list_no_auth_required(
464 self, client: AsyncClient, db_session: AsyncSession
465 ) -> None:
466 await _seed_repo(db_session, owner="sym-noauth", slug="sym-noauth-repo")
467 resp = await client.get("/sym-noauth/sym-noauth-repo/symbols")
468 assert resp.status_code != 401
469
470 async def test_symbols_list_unknown_repo_404(
471 self, client: AsyncClient, db_session: AsyncSession
472 ) -> None:
473 resp = await client.get("/nobody/unknown-sym-repo-xyz/symbols")
474 assert resp.status_code == 404
475
476 async def test_symbol_detail_unknown_repo_404(
477 self, client: AsyncClient, db_session: AsyncSession
478 ) -> None:
479 resp = await client.get("/nobody/unknown-sym-repo-xyz/symbol/file.py::MyFn")
480 assert resp.status_code == 404
481
482
483 class TestProposalsPageE2E:
484 """E2E: proposals pages — supplementing the minimal ssr file."""
485
486 async def test_proposal_list_returns_200(
487 self, client: AsyncClient, db_session: AsyncSession
488 ) -> None:
489 await _seed_repo(db_session, owner="proposal-e2e", slug="proposal-e2e-repo")
490 resp = await client.get("/proposal-e2e/proposal-e2e-repo/proposals")
491 assert resp.status_code == 200
492
493 async def test_proposal_list_html(
494 self, client: AsyncClient, db_session: AsyncSession
495 ) -> None:
496 await _seed_repo(db_session, owner="proposal-e2e2", slug="proposal-e2e-repo2")
497 resp = await client.get("/proposal-e2e2/proposal-e2e-repo2/proposals")
498 assert "text/html" in resp.headers.get("content-type", "")
499
500 async def test_proposal_detail_404_for_unknown_pr_id(
501 self, client: AsyncClient, db_session: AsyncSession
502 ) -> None:
503 await _seed_repo(db_session, owner="proposal-e2e3", slug="proposal-e2e-repo3")
504 fake_id = secrets.token_hex(16)
505 resp = await client.get(f"/proposal-e2e3/proposal-e2e-repo3/proposals/{fake_id}")
506 assert resp.status_code == 404
507
508 async def test_proposal_detail_renders_pr_title(
509 self, client: AsyncClient, db_session: AsyncSession
510 ) -> None:
511 repo = await _seed_repo(db_session, owner="proposal-detail", slug="proposal-detail-repo")
512 proposal = await _seed_proposal(
513 db_session, str(repo.repo_id), title="Unique proposal XYZ"
514 )
515 # URL uses proposal_id, not proposal_number
516 resp = await client.get(
517 f"/proposal-detail/proposal-detail-repo/proposals/{proposal.proposal_id}"
518 )
519 assert resp.status_code == 200
520 assert "Unique proposal XYZ" in resp.text
521
522
523 class TestIntelPageE2E:
524 """E2E: intel pages — no existing test file."""
525
526 async def test_intel_page_returns_200_or_empty(
527 self, client: AsyncClient, db_session: AsyncSession
528 ) -> None:
529 await _seed_repo(db_session, owner="intel-owner", slug="intel-repo")
530 resp = await client.get("/intel-owner/intel-repo/intel")
531 assert resp.status_code in (200, 404)
532
533 async def test_intel_dead_page_no_500(
534 self, client: AsyncClient, db_session: AsyncSession
535 ) -> None:
536 await _seed_repo(db_session, owner="intel-dead", slug="intel-dead-repo")
537 resp = await client.get("/intel-dead/intel-dead-repo/intel/dead")
538 assert resp.status_code != 500
539
540
541 class TestBlobAndRawE2E:
542 """E2E: blob and raw file pages — no existing test file."""
543
544 async def test_blob_page_404_for_unknown_repo(
545 self, client: AsyncClient, db_session: AsyncSession
546 ) -> None:
547 resp = await client.get("/nobody/unknown-blob-repo/blob/HEAD/README.md")
548 assert resp.status_code == 404
549
550 async def test_raw_file_404_for_unknown_repo(
551 self, client: AsyncClient, db_session: AsyncSession
552 ) -> None:
553 resp = await client.get("/nobody/unknown-raw-repo/raw/HEAD/README.md")
554 assert resp.status_code == 404
555
556 async def test_blob_page_404_for_unknown_file(
557 self, client: AsyncClient, db_session: AsyncSession
558 ) -> None:
559 await _seed_repo(db_session, owner="blob-owner", slug="blob-repo")
560 resp = await client.get("/blob-owner/blob-repo/blob/HEAD/nonexistent.md")
561 assert resp.status_code in (200, 404) # empty repo may return 404 or empty page
562
563
564 class TestHTMXFragmentRoutingE2E:
565 """E2E: HTMX fragment routing for pages that support it."""
566
567 async def test_issue_list_htmx_returns_fragment(
568 self, client: AsyncClient, db_session: AsyncSession
569 ) -> None:
570 repo = await _seed_repo(db_session, owner="htmx-frag", slug="htmx-frag-repo")
571 await _seed_issue(db_session, str(repo.repo_id), title="HTMX test issue")
572 resp = await client.get(
573 "/htmx-frag/htmx-frag-repo/issues",
574 headers={"HX-Request": "true"},
575 )
576 assert resp.status_code == 200
577 assert "<html" not in resp.text.lower()
578
579 async def test_issue_list_htmx_boosted_returns_full_page(
580 self, client: AsyncClient, db_session: AsyncSession
581 ) -> None:
582 await _seed_repo(db_session, owner="htmx-boost", slug="htmx-boost-repo")
583 resp = await client.get(
584 "/htmx-boost/htmx-boost-repo/issues",
585 headers={"HX-Request": "true", "HX-Boosted": "true"},
586 )
587 # Boosted requests must get the full page
588 assert resp.status_code == 200
589
590 async def test_search_page_returns_200(
591 self, client: AsyncClient, db_session: AsyncSession
592 ) -> None:
593 resp = await client.get("/search", params={"q": "test"})
594 assert resp.status_code == 200
595
596 async def test_explore_page_returns_200(
597 self, client: AsyncClient, db_session: AsyncSession
598 ) -> None:
599 resp = await client.get("/explore")
600 assert resp.status_code == 200
601
602
603 # ─────────────────────────────────────────────────────────────────────────────
604 # LAYER 4 — STRESS
605 # ─────────────────────────────────────────────────────────────────────────────
606
607
608 class TestUISSRStress:
609 """Stress: sequential page loads and empty-state robustness."""
610
611 async def test_multiple_repo_pages_sequential(
612 self, client: AsyncClient, db_session: AsyncSession
613 ) -> None:
614 """Load 5 different repos' pages in sequence — none should 500."""
615 for i in range(5):
616 owner = f"stress-owner-{i}"
617 slug = f"stress-repo-{i}"
618 await _seed_repo(db_session, owner=owner, slug=slug)
619 resp = await client.get(f"/{owner}/{slug}")
620 assert resp.status_code == 200, f"Repo {i} returned {resp.status_code}"
621
622 async def test_issue_list_with_many_issues(
623 self, client: AsyncClient, db_session: AsyncSession
624 ) -> None:
625 """Issue list renders correctly with 20 seeded issues."""
626 repo = await _seed_repo(db_session, owner="stress-issues", slug="stress-iss-repo")
627 for i in range(20):
628 await _seed_issue(
629 db_session, str(repo.repo_id), number=i + 1, title=f"Issue {i}"
630 )
631 resp = await client.get("/stress-issues/stress-iss-repo/issues")
632 assert resp.status_code == 200
633
634 async def test_empty_state_repo_home(
635 self, client: AsyncClient, db_session: AsyncSession
636 ) -> None:
637 """Repo with no commits/files renders without 500."""
638 await _seed_repo(db_session, owner="empty-owner", slug="empty-repo")
639 resp = await client.get("/empty-owner/empty-repo")
640 assert resp.status_code == 200
641
642 async def test_empty_state_proposals_page(
643 self, client: AsyncClient, db_session: AsyncSession
644 ) -> None:
645 """Proposals page with no proposals renders without 500."""
646 await _seed_repo(db_session, owner="empty-proposal", slug="empty-proposal-repo")
647 resp = await client.get("/empty-proposal/empty-proposal-repo/proposals")
648 assert resp.status_code == 200
649
650 async def test_empty_state_symbols_page(
651 self, client: AsyncClient, db_session: AsyncSession
652 ) -> None:
653 """Symbols page with no symbol index renders without 500."""
654 await _seed_repo(db_session, owner="empty-sym", slug="empty-sym-repo")
655 resp = await client.get("/empty-sym/empty-sym-repo/symbols")
656 assert resp.status_code == 200
657
658 async def test_topics_page_returns_200(
659 self, client: AsyncClient, db_session: AsyncSession
660 ) -> None:
661 resp = await client.get("/topics")
662 assert resp.status_code == 200
663
664 async def test_search_page_empty_query_200(
665 self, client: AsyncClient, db_session: AsyncSession
666 ) -> None:
667 resp = await client.get("/search")
668 assert resp.status_code == 200
669
670 async def test_concurrent_issue_list_requests(
671 self, client: AsyncClient, db_session: AsyncSession
672 ) -> None:
673 """3 sequential requests to the same issues page succeed."""
674 repo = await _seed_repo(db_session, owner="conc-owner", slug="conc-repo")
675 await _seed_issue(db_session, str(repo.repo_id), title="Concurrent test")
676 for _ in range(3):
677 resp = await client.get("/conc-owner/conc-repo/issues")
678 assert resp.status_code == 200
679
680
681 # ─────────────────────────────────────────────────────────────────────────────
682 # LAYER 5 — DATA INTEGRITY
683 # ─────────────────────────────────────────────────────────────────────────────
684
685
686 class TestUISSRDataIntegrity:
687 """Data integrity: nav counts correct, filter accuracy, XSS escaping."""
688
689 async def test_issue_open_count_matches_db(
690 self, client: AsyncClient, db_session: AsyncSession
691 ) -> None:
692 """Nav tab shows the correct open issue count seeded in DB."""
693 repo = await _seed_repo(db_session, owner="count-owner", slug="count-repo")
694 for i in range(3):
695 await _seed_issue(
696 db_session, str(repo.repo_id), number=i + 1, state="open"
697 )
698 resp = await client.get("/count-owner/count-repo/issues")
699 assert resp.status_code == 200
700 # The number 3 should appear in the open tab count
701 assert "3" in resp.text
702
703 async def test_closed_issues_not_shown_on_open_tab(
704 self, client: AsyncClient, db_session: AsyncSession
705 ) -> None:
706 repo = await _seed_repo(db_session, owner="filter-owner", slug="filter-repo")
707 await _seed_issue(
708 db_session, str(repo.repo_id), number=1, title="Open issue", state="open"
709 )
710 await _seed_issue(
711 db_session,
712 str(repo.repo_id),
713 number=2,
714 title="Closed issue xyz",
715 state="closed",
716 )
717 resp = await client.get("/filter-owner/filter-repo/issues", params={"state": "open"})
718 assert resp.status_code == 200
719 assert "Open issue" in resp.text
720 assert "Closed issue xyz" not in resp.text
721
722 async def test_open_issues_not_shown_on_closed_tab(
723 self, client: AsyncClient, db_session: AsyncSession
724 ) -> None:
725 repo = await _seed_repo(db_session, owner="filter-owner2", slug="filter-repo2")
726 await _seed_issue(
727 db_session, str(repo.repo_id), number=1, title="Open issue abc", state="open"
728 )
729 await _seed_issue(
730 db_session,
731 str(repo.repo_id),
732 number=2,
733 title="Closed issue only",
734 state="closed",
735 )
736 resp = await client.get(
737 "/filter-owner2/filter-repo2/issues", params={"state": "closed"}
738 )
739 assert resp.status_code == 200
740 assert "Closed issue only" in resp.text
741 assert "Open issue abc" not in resp.text
742
743 async def test_proposal_title_rendered_in_list(
744 self, client: AsyncClient, db_session: AsyncSession
745 ) -> None:
746 repo = await _seed_repo(db_session, owner="proposal-render", slug="proposal-render-repo")
747 await _seed_proposal(
748 db_session, str(repo.repo_id), title="Piano roll refactor"
749 )
750 resp = await client.get("/proposal-render/proposal-render-repo/proposals")
751 assert "Piano roll refactor" in resp.text
752
753 async def test_xss_title_escaped_in_issue_list(
754 self, client: AsyncClient, db_session: AsyncSession
755 ) -> None:
756 """Issue titles with HTML special chars must be escaped."""
757 repo = await _seed_repo(db_session, owner="xss-owner", slug="xss-repo")
758 xss_title = '<script>alert("xss")</script>'
759 await _seed_issue(db_session, str(repo.repo_id), title=xss_title)
760 resp = await client.get("/xss-owner/xss-repo/issues")
761 assert resp.status_code == 200
762 # The raw script tag must not appear unescaped
763 assert "<script>alert" not in resp.text
764 # The escaped version must be present instead
765 assert "&lt;script&gt;" in resp.text or "alert" not in resp.text
766
767 async def test_xss_in_proposal_title_escaped(
768 self, client: AsyncClient, db_session: AsyncSession
769 ) -> None:
770 repo = await _seed_repo(db_session, owner="xss-proposal", slug="xss-proposal-repo")
771 xss_title = "<img src=x onerror=alert(1)>"
772 await _seed_proposal(db_session, str(repo.repo_id), title=xss_title)
773 resp = await client.get("/xss-proposal/xss-proposal-repo/proposals")
774 assert resp.status_code == 200
775 assert "<img src=x onerror" not in resp.text
776
777 async def test_owner_name_in_repo_home_html(
778 self, client: AsyncClient, db_session: AsyncSession
779 ) -> None:
780 await _seed_repo(db_session, owner="render-owner-z", slug="render-repo-z")
781 resp = await client.get("/render-owner-z/render-repo-z")
782 assert "render-owner-z" in resp.text
783
784 async def test_repo_slug_in_repo_home_html(
785 self, client: AsyncClient, db_session: AsyncSession
786 ) -> None:
787 await _seed_repo(db_session, owner="slug-owner", slug="visible-slug-abc")
788 resp = await client.get("/slug-owner/visible-slug-abc")
789 assert "visible-slug-abc" in resp.text
790
791
792 # ─────────────────────────────────────────────────────────────────────────────
793 # LAYER 6 — SECURITY
794 # ─────────────────────────────────────────────────────────────────────────────
795
796
797 class TestUISSRSecurity:
798 """Security: auth enforcement, XSS, no debug leaks."""
799
800 async def test_create_repo_post_401_without_auth(
801 self, client: AsyncClient, db_session: AsyncSession
802 ) -> None:
803 """POST /new (create repo wizard) requires a valid token — no token → 401."""
804 resp = await client.post(
805 "/new",
806 json={"name": "test-repo", "owner": "sec-owner", "visibility": "public"},
807 )
808 assert resp.status_code == 401
809
810 async def test_labels_post_401_without_auth(
811 self, client: AsyncClient, db_session: AsyncSession
812 ) -> None:
813 """POST to label mutations requires auth — no token → 401."""
814 repo = await _seed_repo(db_session, owner="sec-lbl", slug="sec-lbl-repo")
815 resp = await client.post(
816 f"/api/repos/{repo.repo_id}/labels",
817 json={"name": "bug", "color": "#ff0000"},
818 )
819 assert resp.status_code == 401
820
821 async def test_new_repo_get_returns_200_or_redirect(
822 self, client: AsyncClient, db_session: AsyncSession
823 ) -> None:
824 """GET /new may require auth — should not 500."""
825 resp = await client.get("/new")
826 assert resp.status_code in (200, 401, 302, 303)
827
828 async def test_no_traceback_on_404(
829 self, client: AsyncClient, db_session: AsyncSession
830 ) -> None:
831 resp = await client.get("/nobody/no-such-repo-xyzabc")
832 assert "Traceback" not in resp.text
833
834 async def test_no_traceback_on_repo_home(
835 self, client: AsyncClient, db_session: AsyncSession
836 ) -> None:
837 await _seed_repo(db_session, owner="notrace-owner", slug="notrace-repo")
838 resp = await client.get("/notrace-owner/notrace-repo")
839 assert "Traceback" not in resp.text
840
841 async def test_no_stack_trace_on_issue_list(
842 self, client: AsyncClient, db_session: AsyncSession
843 ) -> None:
844 await _seed_repo(db_session, owner="notrace2", slug="notrace-repo2")
845 resp = await client.get("/notrace2/notrace-repo2/issues")
846 assert "Traceback" not in resp.text
847
848 async def test_private_repo_home_does_not_500(
849 self, client: AsyncClient, db_session: AsyncSession
850 ) -> None:
851 """Private repo home renders without error (visibility enforced at write APIs)."""
852 await _seed_repo(
853 db_session, owner="priv-owner", slug="priv-repo", visibility="private"
854 )
855 resp = await client.get("/priv-owner/priv-repo")
856 # Read layer is publicly accessible; write mutations require auth
857 assert resp.status_code != 500
858
859 async def test_xss_in_query_param_not_reflected_raw(
860 self, client: AsyncClient, db_session: AsyncSession
861 ) -> None:
862 """Search query containing XSS payload must not be reflected unescaped."""
863 resp = await client.get("/search", params={"q": '<script>alert(1)</script>'})
864 assert "<script>alert(1)</script>" not in resp.text
865
866 async def test_no_debug_info_in_production_errors(
867 self, client: AsyncClient, db_session: AsyncSession
868 ) -> None:
869 """404 responses must not contain Python module paths."""
870 resp = await client.get("/nobody/sec-unknown-repo-xyz")
871 assert "/Users/" not in resp.text
872 assert "musehub/" not in resp.text or resp.status_code != 500
873
874 async def test_sql_injection_in_owner_does_not_500(
875 self, client: AsyncClient, db_session: AsyncSession
876 ) -> None:
877 """URL path parameters passed as SQL injection should result in 404, not 500."""
878 resp = await client.get("/'; DROP TABLE musehub_repos; --/repo")
879 assert resp.status_code != 500
880
881
882 # ─────────────────────────────────────────────────────────────────────────────
883 # LAYER 7 — PERFORMANCE
884 # ─────────────────────────────────────────────────────────────────────────────
885
886
887 class TestUISSRPerformance:
888 """Performance: page responses under time budgets."""
889
890 async def test_repo_home_under_500ms(
891 self, client: AsyncClient, db_session: AsyncSession
892 ) -> None:
893 await _seed_repo(db_session, owner="perf-owner", slug="perf-repo")
894 # Warm-up
895 await client.get("/perf-owner/perf-repo")
896 start = time.perf_counter()
897 resp = await client.get("/perf-owner/perf-repo")
898 elapsed = time.perf_counter() - start
899 assert resp.status_code == 200
900 assert elapsed < 0.500, f"Repo home took {elapsed*1000:.1f}ms (limit 500ms)"
901
902 async def test_issue_list_under_500ms(
903 self, client: AsyncClient, db_session: AsyncSession
904 ) -> None:
905 repo = await _seed_repo(db_session, owner="perf-iss", slug="perf-iss-repo")
906 for i in range(5):
907 await _seed_issue(
908 db_session, str(repo.repo_id), number=i + 1, title=f"Issue {i}"
909 )
910 await client.get("/perf-iss/perf-iss-repo/issues")
911 start = time.perf_counter()
912 resp = await client.get("/perf-iss/perf-iss-repo/issues")
913 elapsed = time.perf_counter() - start
914 assert resp.status_code == 200
915 assert elapsed < 0.500, f"Issue list took {elapsed*1000:.1f}ms (limit 500ms)"
916
917 async def test_proposals_page_under_500ms(
918 self, client: AsyncClient, db_session: AsyncSession
919 ) -> None:
920 repo = await _seed_repo(db_session, owner="perf-proposal", slug="perf-proposal-repo")
921 await _seed_proposal(db_session, str(repo.repo_id), title="Perf test proposal")
922 await client.get("/perf-proposal/perf-proposal-repo/proposals")
923 start = time.perf_counter()
924 resp = await client.get("/perf-proposal/perf-proposal-repo/proposals")
925 elapsed = time.perf_counter() - start
926 assert resp.status_code == 200
927 assert elapsed < 0.500, f"Proposals page took {elapsed*1000:.1f}ms (limit 500ms)"
928
929 async def test_explore_page_under_500ms(
930 self, client: AsyncClient, db_session: AsyncSession
931 ) -> None:
932 await client.get("/explore") # warm-up
933 start = time.perf_counter()
934 resp = await client.get("/explore")
935 elapsed = time.perf_counter() - start
936 assert resp.status_code == 200
937 assert elapsed < 0.500, f"Explore page took {elapsed*1000:.1f}ms (limit 500ms)"
938
939 async def test_search_page_under_500ms(
940 self, client: AsyncClient, db_session: AsyncSession
941 ) -> None:
942 await client.get("/search")
943 start = time.perf_counter()
944 resp = await client.get("/search", params={"q": "test"})
945 elapsed = time.perf_counter() - start
946 assert resp.status_code == 200
947 assert elapsed < 0.500, f"Search page took {elapsed*1000:.1f}ms (limit 500ms)"
948
949 def test_to_camel_via_htmx_helpers_import_fast(self) -> None:
950 """Importing htmx_helpers is fast (module already loaded)."""
951 start = time.perf_counter()
952 from musehub.api.routes.musehub.htmx_helpers import is_htmx, htmx_trigger # noqa: F401
953 elapsed = time.perf_counter() - start
954 assert elapsed < 0.050, f"Import took {elapsed*1000:.1f}ms (limit 50ms)"
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 123 days ago