gabriel / musehub public

test_ui_ssr.py file-level

at sha256:3 · View file ↗ · Intel ↗

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