gabriel / musehub public
test_ui_ssr.py python
953 lines 39.6 KB
Raw
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff fix(tests): update test suite to match current implementation Sonnet 4.6 patch 120 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_function(self) -> None:
258 assert self._infer("file.py::my_function") == "function"
259
260 def test_all_caps_is_variable(self) -> None:
261 assert self._infer("file.py::MAX_RETRIES") == "variable"
262
263 def test_no_namespace_camel(self) -> None:
264 assert self._infer("CamelCase") == "file"
265
266 def test_no_namespace_snake(self) -> None:
267 assert self._infer("my_func") == "file"
268
269 def test_private_fn_underscore_prefix(self) -> None:
270 assert self._infer("_private_func") == "file"
271
272 def test_dunder_is_file_without_namespace(self) -> None:
273 assert self._infer("__init__") == "file"
274
275 def test_empty_addr_is_file(self) -> None:
276 assert self._infer("") == "file"
277
278 def test_private_class_without_namespace(self) -> None:
279 assert self._infer("_PrivateClass") == "file"
280
281
282 class TestLicensesForViewerType:
283 """Unit tests for licenses_for_viewer_type."""
284
285 def test_symbol_graph_returns_code_licenses(self) -> None:
286 from musehub.api.routes.musehub.ui_new_repo import licenses_for_viewer_type
287 result = licenses_for_viewer_type("symbol_graph")
288 assert isinstance(result, list)
289 assert len(result) > 0
290 assert all(isinstance(item, tuple) and len(item) == 2 for item in result)
291
292 def test_default_returns_generic_licenses(self) -> None:
293 from musehub.api.routes.musehub.ui_new_repo import licenses_for_viewer_type
294 default = licenses_for_viewer_type("audio")
295 code = licenses_for_viewer_type("symbol_graph")
296 # They may overlap but should be distinct lists
297 assert isinstance(default, list)
298
299 def test_unknown_type_returns_list(self) -> None:
300 from musehub.api.routes.musehub.ui_new_repo import licenses_for_viewer_type
301 result = licenses_for_viewer_type("totally_unknown")
302 assert isinstance(result, list)
303
304
305 # ─────────────────────────────────────────────────────────────────────────────
306 # LAYER 2 — INTEGRATION
307 # ─────────────────────────────────────────────────────────────────────────────
308
309
310 class TestRepoPageIntegration:
311 """Integration: repo home page with real DB (no HTTP)."""
312
313 async def test_repo_page_200_with_empty_repo(
314 self, client: AsyncClient, db_session: AsyncSession
315 ) -> None:
316 repo = await _seed_repo(db_session, owner="int-owner", slug="int-repo")
317 resp = await client.get(f"/int-owner/int-repo")
318 assert resp.status_code == 200
319
320 async def test_repo_page_404_for_unknown_slug(
321 self, client: AsyncClient, db_session: AsyncSession
322 ) -> None:
323 resp = await client.get("/nobody/totally-unknown-repo-xyz")
324 assert resp.status_code == 404
325
326 async def test_repo_page_shows_owner_in_html(
327 self, client: AsyncClient, db_session: AsyncSession
328 ) -> None:
329 await _seed_repo(db_session, owner="int-owner2", slug="int-repo2")
330 resp = await client.get("/int-owner2/int-repo2")
331 assert resp.status_code == 200
332 assert "int-owner2" in resp.text
333
334
335 class TestProposalListIntegration:
336 """Integration: proposals list page with real DB."""
337
338 async def test_proposal_list_200_empty(
339 self, client: AsyncClient, db_session: AsyncSession
340 ) -> None:
341 await _seed_repo(db_session, owner="prop-owner", slug="prop-repo")
342 resp = await client.get("/prop-owner/prop-repo/proposals")
343 assert resp.status_code == 200
344
345 async def test_proposal_list_shows_pr_title(
346 self, client: AsyncClient, db_session: AsyncSession
347 ) -> None:
348 repo = await _seed_repo(db_session, owner="prop-owner2", slug="prop-repo2")
349 await _seed_proposal(db_session, str(repo.repo_id), title="Reverb on chorus")
350 resp = await client.get("/prop-owner2/prop-repo2/proposals")
351 assert resp.status_code == 200
352 assert "Reverb on chorus" in resp.text
353
354 async def test_proposal_list_404_for_unknown_repo(
355 self, client: AsyncClient, db_session: AsyncSession
356 ) -> None:
357 resp = await client.get("/nobody/unknown-proposals-repo/proposals")
358 assert resp.status_code == 404
359
360
361 class TestAgentsPageIntegration:
362 """Integration: agents swarm / coord pages with real DB."""
363
364 async def test_agents_swarm_page_200(
365 self, client: AsyncClient, db_session: AsyncSession
366 ) -> None:
367 await _seed_repo(db_session, owner="agent-owner", slug="agent-repo")
368 resp = await client.get("/agent-owner/agent-repo/agents/swarm")
369 assert resp.status_code in (200, 404) # 404 if page not registered at /agents/swarm
370
371 async def test_agents_coord_page_200(
372 self, client: AsyncClient, db_session: AsyncSession
373 ) -> None:
374 await _seed_repo(db_session, owner="agent-owner2", slug="agent-repo2")
375 resp = await client.get("/agent-owner2/agent-repo2/agents/coord")
376 assert resp.status_code in (200, 404)
377
378
379 # ─────────────────────────────────────────────────────────────────────────────
380 # LAYER 3 — E2E
381 # ─────────────────────────────────────────────────────────────────────────────
382
383
384 class TestRepoHomeE2E:
385 """E2E: repo home page (/{owner}/{slug}) — no existing test file."""
386
387 async def test_get_repo_home_returns_200(
388 self, client: AsyncClient, db_session: AsyncSession
389 ) -> None:
390 await _seed_repo(db_session, owner="e2e-home", slug="home-repo")
391 resp = await client.get("/e2e-home/home-repo")
392 assert resp.status_code == 200
393
394 async def test_repo_home_returns_html(
395 self, client: AsyncClient, db_session: AsyncSession
396 ) -> None:
397 await _seed_repo(db_session, owner="e2e-html", slug="html-repo")
398 resp = await client.get("/e2e-html/html-repo")
399 assert "text/html" in resp.headers.get("content-type", "")
400
401 async def test_repo_home_no_auth_required(
402 self, client: AsyncClient, db_session: AsyncSession
403 ) -> None:
404 """Public repos should be accessible without authentication."""
405 await _seed_repo(db_session, owner="e2e-pub", slug="pub-repo", visibility="public")
406 resp = await client.get("/e2e-pub/pub-repo")
407 assert resp.status_code != 401
408
409 async def test_repo_home_json_format_returns_json(
410 self, client: AsyncClient, db_session: AsyncSession
411 ) -> None:
412 """?format=json should return JSON instead of HTML."""
413 await _seed_repo(db_session, owner="e2e-json", slug="json-repo")
414 resp = await client.get("/e2e-json/json-repo", params={"format": "json"})
415 assert resp.status_code == 200
416 assert "application/json" in resp.headers.get("content-type", "")
417
418 async def test_repo_home_accept_json_returns_json(
419 self, client: AsyncClient, db_session: AsyncSession
420 ) -> None:
421 """Accept: application/json triggers the JSON shortcut."""
422 await _seed_repo(db_session, owner="e2e-acc", slug="acc-repo")
423 resp = await client.get(
424 "/e2e-acc/acc-repo",
425 headers={"Accept": "application/json"},
426 )
427 assert resp.status_code == 200
428 data = resp.json()
429 assert "slug" in data or "repoId" in data
430
431 async def test_repo_home_htmx_returns_fragment(
432 self, client: AsyncClient, db_session: AsyncSession
433 ) -> None:
434 """HX-Request returns file_tree fragment, not full page."""
435 await _seed_repo(db_session, owner="e2e-htmx", slug="htmx-repo")
436 resp = await client.get(
437 "/e2e-htmx/htmx-repo",
438 headers={"HX-Request": "true"},
439 )
440 # Fragment should NOT contain the full <html> wrapper
441 assert resp.status_code == 200
442 body = resp.text
443 assert "<html" not in body.lower()
444
445 async def test_repo_home_unknown_repo_returns_404(
446 self, client: AsyncClient, db_session: AsyncSession
447 ) -> None:
448 resp = await client.get("/nobody/repo-that-does-not-exist-xyz-abc")
449 assert resp.status_code == 404
450
451
452 class TestSymbolsPageE2E:
453 """E2E: symbols page (/{owner}/{slug}/symbols) — no existing test file."""
454
455 async def test_symbols_list_page_returns_200(
456 self, client: AsyncClient, db_session: AsyncSession
457 ) -> None:
458 await _seed_repo(db_session, owner="sym-owner", slug="sym-repo")
459 resp = await client.get("/sym-owner/sym-repo/symbols")
460 assert resp.status_code == 200
461
462 async def test_symbols_list_no_auth_required(
463 self, client: AsyncClient, db_session: AsyncSession
464 ) -> None:
465 await _seed_repo(db_session, owner="sym-noauth", slug="sym-noauth-repo")
466 resp = await client.get("/sym-noauth/sym-noauth-repo/symbols")
467 assert resp.status_code != 401
468
469 async def test_symbols_list_unknown_repo_404(
470 self, client: AsyncClient, db_session: AsyncSession
471 ) -> None:
472 resp = await client.get("/nobody/unknown-sym-repo-xyz/symbols")
473 assert resp.status_code == 404
474
475 async def test_symbol_detail_unknown_repo_404(
476 self, client: AsyncClient, db_session: AsyncSession
477 ) -> None:
478 resp = await client.get("/nobody/unknown-sym-repo-xyz/symbol/file.py::MyFn")
479 assert resp.status_code == 404
480
481
482 class TestProposalsPageE2E:
483 """E2E: proposals pages — supplementing the minimal ssr file."""
484
485 async def test_proposal_list_returns_200(
486 self, client: AsyncClient, db_session: AsyncSession
487 ) -> None:
488 await _seed_repo(db_session, owner="proposal-e2e", slug="proposal-e2e-repo")
489 resp = await client.get("/proposal-e2e/proposal-e2e-repo/proposals")
490 assert resp.status_code == 200
491
492 async def test_proposal_list_html(
493 self, client: AsyncClient, db_session: AsyncSession
494 ) -> None:
495 await _seed_repo(db_session, owner="proposal-e2e2", slug="proposal-e2e-repo2")
496 resp = await client.get("/proposal-e2e2/proposal-e2e-repo2/proposals")
497 assert "text/html" in resp.headers.get("content-type", "")
498
499 async def test_proposal_detail_404_for_unknown_pr_id(
500 self, client: AsyncClient, db_session: AsyncSession
501 ) -> None:
502 await _seed_repo(db_session, owner="proposal-e2e3", slug="proposal-e2e-repo3")
503 fake_id = secrets.token_hex(16)
504 resp = await client.get(f"/proposal-e2e3/proposal-e2e-repo3/proposals/{fake_id}")
505 assert resp.status_code == 404
506
507 async def test_proposal_detail_renders_pr_title(
508 self, client: AsyncClient, db_session: AsyncSession
509 ) -> None:
510 repo = await _seed_repo(db_session, owner="proposal-detail", slug="proposal-detail-repo")
511 proposal = await _seed_proposal(
512 db_session, str(repo.repo_id), title="Unique proposal XYZ"
513 )
514 # URL uses proposal_id, not proposal_number
515 resp = await client.get(
516 f"/proposal-detail/proposal-detail-repo/proposals/{proposal.proposal_id}"
517 )
518 assert resp.status_code == 200
519 assert "Unique proposal XYZ" in resp.text
520
521
522 class TestIntelPageE2E:
523 """E2E: intel pages — no existing test file."""
524
525 async def test_intel_page_returns_200_or_empty(
526 self, client: AsyncClient, db_session: AsyncSession
527 ) -> None:
528 await _seed_repo(db_session, owner="intel-owner", slug="intel-repo")
529 resp = await client.get("/intel-owner/intel-repo/intel")
530 assert resp.status_code in (200, 404)
531
532 async def test_intel_dead_page_no_500(
533 self, client: AsyncClient, db_session: AsyncSession
534 ) -> None:
535 await _seed_repo(db_session, owner="intel-dead", slug="intel-dead-repo")
536 resp = await client.get("/intel-dead/intel-dead-repo/intel/dead")
537 assert resp.status_code != 500
538
539
540 class TestBlobAndRawE2E:
541 """E2E: blob and raw file pages — no existing test file."""
542
543 async def test_blob_page_404_for_unknown_repo(
544 self, client: AsyncClient, db_session: AsyncSession
545 ) -> None:
546 resp = await client.get("/nobody/unknown-blob-repo/blob/HEAD/README.md")
547 assert resp.status_code == 404
548
549 async def test_raw_file_404_for_unknown_repo(
550 self, client: AsyncClient, db_session: AsyncSession
551 ) -> None:
552 resp = await client.get("/nobody/unknown-raw-repo/raw/HEAD/README.md")
553 assert resp.status_code == 404
554
555 async def test_blob_page_404_for_unknown_file(
556 self, client: AsyncClient, db_session: AsyncSession
557 ) -> None:
558 await _seed_repo(db_session, owner="blob-owner", slug="blob-repo")
559 resp = await client.get("/blob-owner/blob-repo/blob/HEAD/nonexistent.md")
560 assert resp.status_code in (200, 404) # empty repo may return 404 or empty page
561
562
563 class TestHTMXFragmentRoutingE2E:
564 """E2E: HTMX fragment routing for pages that support it."""
565
566 async def test_issue_list_htmx_returns_fragment(
567 self, client: AsyncClient, db_session: AsyncSession
568 ) -> None:
569 repo = await _seed_repo(db_session, owner="htmx-frag", slug="htmx-frag-repo")
570 await _seed_issue(db_session, str(repo.repo_id), title="HTMX test issue")
571 resp = await client.get(
572 "/htmx-frag/htmx-frag-repo/issues",
573 headers={"HX-Request": "true"},
574 )
575 assert resp.status_code == 200
576 assert "<html" not in resp.text.lower()
577
578 async def test_issue_list_htmx_boosted_returns_full_page(
579 self, client: AsyncClient, db_session: AsyncSession
580 ) -> None:
581 await _seed_repo(db_session, owner="htmx-boost", slug="htmx-boost-repo")
582 resp = await client.get(
583 "/htmx-boost/htmx-boost-repo/issues",
584 headers={"HX-Request": "true", "HX-Boosted": "true"},
585 )
586 # Boosted requests must get the full page
587 assert resp.status_code == 200
588
589 async def test_search_page_returns_200(
590 self, client: AsyncClient, db_session: AsyncSession
591 ) -> None:
592 resp = await client.get("/search", params={"q": "test"})
593 assert resp.status_code == 200
594
595 async def test_explore_page_returns_200(
596 self, client: AsyncClient, db_session: AsyncSession
597 ) -> None:
598 resp = await client.get("/explore")
599 assert resp.status_code == 200
600
601
602 # ─────────────────────────────────────────────────────────────────────────────
603 # LAYER 4 — STRESS
604 # ─────────────────────────────────────────────────────────────────────────────
605
606
607 class TestUISSRStress:
608 """Stress: sequential page loads and empty-state robustness."""
609
610 async def test_multiple_repo_pages_sequential(
611 self, client: AsyncClient, db_session: AsyncSession
612 ) -> None:
613 """Load 5 different repos' pages in sequence — none should 500."""
614 for i in range(5):
615 owner = f"stress-owner-{i}"
616 slug = f"stress-repo-{i}"
617 await _seed_repo(db_session, owner=owner, slug=slug)
618 resp = await client.get(f"/{owner}/{slug}")
619 assert resp.status_code == 200, f"Repo {i} returned {resp.status_code}"
620
621 async def test_issue_list_with_many_issues(
622 self, client: AsyncClient, db_session: AsyncSession
623 ) -> None:
624 """Issue list renders correctly with 20 seeded issues."""
625 repo = await _seed_repo(db_session, owner="stress-issues", slug="stress-iss-repo")
626 for i in range(20):
627 await _seed_issue(
628 db_session, str(repo.repo_id), number=i + 1, title=f"Issue {i}"
629 )
630 resp = await client.get("/stress-issues/stress-iss-repo/issues")
631 assert resp.status_code == 200
632
633 async def test_empty_state_repo_home(
634 self, client: AsyncClient, db_session: AsyncSession
635 ) -> None:
636 """Repo with no commits/files renders without 500."""
637 await _seed_repo(db_session, owner="empty-owner", slug="empty-repo")
638 resp = await client.get("/empty-owner/empty-repo")
639 assert resp.status_code == 200
640
641 async def test_empty_state_proposals_page(
642 self, client: AsyncClient, db_session: AsyncSession
643 ) -> None:
644 """Proposals page with no proposals renders without 500."""
645 await _seed_repo(db_session, owner="empty-proposal", slug="empty-proposal-repo")
646 resp = await client.get("/empty-proposal/empty-proposal-repo/proposals")
647 assert resp.status_code == 200
648
649 async def test_empty_state_symbols_page(
650 self, client: AsyncClient, db_session: AsyncSession
651 ) -> None:
652 """Symbols page with no symbol index renders without 500."""
653 await _seed_repo(db_session, owner="empty-sym", slug="empty-sym-repo")
654 resp = await client.get("/empty-sym/empty-sym-repo/symbols")
655 assert resp.status_code == 200
656
657 async def test_topics_page_returns_200(
658 self, client: AsyncClient, db_session: AsyncSession
659 ) -> None:
660 resp = await client.get("/topics")
661 assert resp.status_code == 200
662
663 async def test_search_page_empty_query_200(
664 self, client: AsyncClient, db_session: AsyncSession
665 ) -> None:
666 resp = await client.get("/search")
667 assert resp.status_code == 200
668
669 async def test_concurrent_issue_list_requests(
670 self, client: AsyncClient, db_session: AsyncSession
671 ) -> None:
672 """3 sequential requests to the same issues page succeed."""
673 repo = await _seed_repo(db_session, owner="conc-owner", slug="conc-repo")
674 await _seed_issue(db_session, str(repo.repo_id), title="Concurrent test")
675 for _ in range(3):
676 resp = await client.get("/conc-owner/conc-repo/issues")
677 assert resp.status_code == 200
678
679
680 # ─────────────────────────────────────────────────────────────────────────────
681 # LAYER 5 — DATA INTEGRITY
682 # ─────────────────────────────────────────────────────────────────────────────
683
684
685 class TestUISSRDataIntegrity:
686 """Data integrity: nav counts correct, filter accuracy, XSS escaping."""
687
688 async def test_issue_open_count_matches_db(
689 self, client: AsyncClient, db_session: AsyncSession
690 ) -> None:
691 """Nav tab shows the correct open issue count seeded in DB."""
692 repo = await _seed_repo(db_session, owner="count-owner", slug="count-repo")
693 for i in range(3):
694 await _seed_issue(
695 db_session, str(repo.repo_id), number=i + 1, state="open"
696 )
697 resp = await client.get("/count-owner/count-repo/issues")
698 assert resp.status_code == 200
699 # The number 3 should appear in the open tab count
700 assert "3" in resp.text
701
702 async def test_closed_issues_not_shown_on_open_tab(
703 self, client: AsyncClient, db_session: AsyncSession
704 ) -> None:
705 repo = await _seed_repo(db_session, owner="filter-owner", slug="filter-repo")
706 await _seed_issue(
707 db_session, str(repo.repo_id), number=1, title="Open issue", state="open"
708 )
709 await _seed_issue(
710 db_session,
711 str(repo.repo_id),
712 number=2,
713 title="Closed issue xyz",
714 state="closed",
715 )
716 resp = await client.get("/filter-owner/filter-repo/issues", params={"state": "open"})
717 assert resp.status_code == 200
718 assert "Open issue" in resp.text
719 assert "Closed issue xyz" not in resp.text
720
721 async def test_open_issues_not_shown_on_closed_tab(
722 self, client: AsyncClient, db_session: AsyncSession
723 ) -> None:
724 repo = await _seed_repo(db_session, owner="filter-owner2", slug="filter-repo2")
725 await _seed_issue(
726 db_session, str(repo.repo_id), number=1, title="Open issue abc", state="open"
727 )
728 await _seed_issue(
729 db_session,
730 str(repo.repo_id),
731 number=2,
732 title="Closed issue only",
733 state="closed",
734 )
735 resp = await client.get(
736 "/filter-owner2/filter-repo2/issues", params={"state": "closed"}
737 )
738 assert resp.status_code == 200
739 assert "Closed issue only" in resp.text
740 assert "Open issue abc" not in resp.text
741
742 async def test_proposal_title_rendered_in_list(
743 self, client: AsyncClient, db_session: AsyncSession
744 ) -> None:
745 repo = await _seed_repo(db_session, owner="proposal-render", slug="proposal-render-repo")
746 await _seed_proposal(
747 db_session, str(repo.repo_id), title="Piano roll refactor"
748 )
749 resp = await client.get("/proposal-render/proposal-render-repo/proposals")
750 assert "Piano roll refactor" in resp.text
751
752 async def test_xss_title_escaped_in_issue_list(
753 self, client: AsyncClient, db_session: AsyncSession
754 ) -> None:
755 """Issue titles with HTML special chars must be escaped."""
756 repo = await _seed_repo(db_session, owner="xss-owner", slug="xss-repo")
757 xss_title = '<script>alert("xss")</script>'
758 await _seed_issue(db_session, str(repo.repo_id), title=xss_title)
759 resp = await client.get("/xss-owner/xss-repo/issues")
760 assert resp.status_code == 200
761 # The raw script tag must not appear unescaped
762 assert "<script>alert" not in resp.text
763 # The escaped version must be present instead
764 assert "&lt;script&gt;" in resp.text or "alert" not in resp.text
765
766 async def test_xss_in_proposal_title_escaped(
767 self, client: AsyncClient, db_session: AsyncSession
768 ) -> None:
769 repo = await _seed_repo(db_session, owner="xss-proposal", slug="xss-proposal-repo")
770 xss_title = "<img src=x onerror=alert(1)>"
771 await _seed_proposal(db_session, str(repo.repo_id), title=xss_title)
772 resp = await client.get("/xss-proposal/xss-proposal-repo/proposals")
773 assert resp.status_code == 200
774 assert "<img src=x onerror" not in resp.text
775
776 async def test_owner_name_in_repo_home_html(
777 self, client: AsyncClient, db_session: AsyncSession
778 ) -> None:
779 await _seed_repo(db_session, owner="render-owner-z", slug="render-repo-z")
780 resp = await client.get("/render-owner-z/render-repo-z")
781 assert "render-owner-z" in resp.text
782
783 async def test_repo_slug_in_repo_home_html(
784 self, client: AsyncClient, db_session: AsyncSession
785 ) -> None:
786 await _seed_repo(db_session, owner="slug-owner", slug="visible-slug-abc")
787 resp = await client.get("/slug-owner/visible-slug-abc")
788 assert "visible-slug-abc" in resp.text
789
790
791 # ─────────────────────────────────────────────────────────────────────────────
792 # LAYER 6 — SECURITY
793 # ─────────────────────────────────────────────────────────────────────────────
794
795
796 class TestUISSRSecurity:
797 """Security: auth enforcement, XSS, no debug leaks."""
798
799 async def test_create_repo_post_401_without_auth(
800 self, client: AsyncClient, db_session: AsyncSession
801 ) -> None:
802 """POST /new (create repo wizard) requires a valid token — no token → 401."""
803 resp = await client.post(
804 "/new",
805 json={"name": "test-repo", "owner": "sec-owner", "visibility": "public"},
806 )
807 assert resp.status_code == 401
808
809 async def test_labels_post_401_without_auth(
810 self, client: AsyncClient, db_session: AsyncSession
811 ) -> None:
812 """POST to label mutations requires auth — no token → 401."""
813 repo = await _seed_repo(db_session, owner="sec-lbl", slug="sec-lbl-repo")
814 resp = await client.post(
815 f"/api/repos/{repo.repo_id}/labels",
816 json={"name": "bug", "color": "#ff0000"},
817 )
818 assert resp.status_code == 401
819
820 async def test_new_repo_get_returns_200_or_redirect(
821 self, client: AsyncClient, db_session: AsyncSession
822 ) -> None:
823 """GET /new may require auth — should not 500."""
824 resp = await client.get("/new")
825 assert resp.status_code in (200, 401, 302, 303)
826
827 async def test_no_traceback_on_404(
828 self, client: AsyncClient, db_session: AsyncSession
829 ) -> None:
830 resp = await client.get("/nobody/no-such-repo-xyzabc")
831 assert "Traceback" not in resp.text
832
833 async def test_no_traceback_on_repo_home(
834 self, client: AsyncClient, db_session: AsyncSession
835 ) -> None:
836 await _seed_repo(db_session, owner="notrace-owner", slug="notrace-repo")
837 resp = await client.get("/notrace-owner/notrace-repo")
838 assert "Traceback" not in resp.text
839
840 async def test_no_stack_trace_on_issue_list(
841 self, client: AsyncClient, db_session: AsyncSession
842 ) -> None:
843 await _seed_repo(db_session, owner="notrace2", slug="notrace-repo2")
844 resp = await client.get("/notrace2/notrace-repo2/issues")
845 assert "Traceback" not in resp.text
846
847 async def test_private_repo_home_does_not_500(
848 self, client: AsyncClient, db_session: AsyncSession
849 ) -> None:
850 """Private repo home renders without error (visibility enforced at write APIs)."""
851 await _seed_repo(
852 db_session, owner="priv-owner", slug="priv-repo", visibility="private"
853 )
854 resp = await client.get("/priv-owner/priv-repo")
855 # Read layer is publicly accessible; write mutations require auth
856 assert resp.status_code != 500
857
858 async def test_xss_in_query_param_not_reflected_raw(
859 self, client: AsyncClient, db_session: AsyncSession
860 ) -> None:
861 """Search query containing XSS payload must not be reflected unescaped."""
862 resp = await client.get("/search", params={"q": '<script>alert(1)</script>'})
863 assert "<script>alert(1)</script>" not in resp.text
864
865 async def test_no_debug_info_in_production_errors(
866 self, client: AsyncClient, db_session: AsyncSession
867 ) -> None:
868 """404 responses must not contain Python module paths."""
869 resp = await client.get("/nobody/sec-unknown-repo-xyz")
870 assert "/Users/" not in resp.text
871 assert "musehub/" not in resp.text or resp.status_code != 500
872
873 async def test_sql_injection_in_owner_does_not_500(
874 self, client: AsyncClient, db_session: AsyncSession
875 ) -> None:
876 """URL path parameters passed as SQL injection should result in 404, not 500."""
877 resp = await client.get("/'; DROP TABLE musehub_repos; --/repo")
878 assert resp.status_code != 500
879
880
881 # ─────────────────────────────────────────────────────────────────────────────
882 # LAYER 7 — PERFORMANCE
883 # ─────────────────────────────────────────────────────────────────────────────
884
885
886 class TestUISSRPerformance:
887 """Performance: page responses under time budgets."""
888
889 async def test_repo_home_under_500ms(
890 self, client: AsyncClient, db_session: AsyncSession
891 ) -> None:
892 await _seed_repo(db_session, owner="perf-owner", slug="perf-repo")
893 # Warm-up
894 await client.get("/perf-owner/perf-repo")
895 start = time.perf_counter()
896 resp = await client.get("/perf-owner/perf-repo")
897 elapsed = time.perf_counter() - start
898 assert resp.status_code == 200
899 assert elapsed < 0.500, f"Repo home took {elapsed*1000:.1f}ms (limit 500ms)"
900
901 async def test_issue_list_under_500ms(
902 self, client: AsyncClient, db_session: AsyncSession
903 ) -> None:
904 repo = await _seed_repo(db_session, owner="perf-iss", slug="perf-iss-repo")
905 for i in range(5):
906 await _seed_issue(
907 db_session, str(repo.repo_id), number=i + 1, title=f"Issue {i}"
908 )
909 await client.get("/perf-iss/perf-iss-repo/issues")
910 start = time.perf_counter()
911 resp = await client.get("/perf-iss/perf-iss-repo/issues")
912 elapsed = time.perf_counter() - start
913 assert resp.status_code == 200
914 assert elapsed < 0.500, f"Issue list took {elapsed*1000:.1f}ms (limit 500ms)"
915
916 async def test_proposals_page_under_500ms(
917 self, client: AsyncClient, db_session: AsyncSession
918 ) -> None:
919 repo = await _seed_repo(db_session, owner="perf-proposal", slug="perf-proposal-repo")
920 await _seed_proposal(db_session, str(repo.repo_id), title="Perf test proposal")
921 await client.get("/perf-proposal/perf-proposal-repo/proposals")
922 start = time.perf_counter()
923 resp = await client.get("/perf-proposal/perf-proposal-repo/proposals")
924 elapsed = time.perf_counter() - start
925 assert resp.status_code == 200
926 assert elapsed < 0.500, f"Proposals page took {elapsed*1000:.1f}ms (limit 500ms)"
927
928 async def test_explore_page_under_500ms(
929 self, client: AsyncClient, db_session: AsyncSession
930 ) -> None:
931 await client.get("/explore") # warm-up
932 start = time.perf_counter()
933 resp = await client.get("/explore")
934 elapsed = time.perf_counter() - start
935 assert resp.status_code == 200
936 assert elapsed < 0.500, f"Explore page took {elapsed*1000:.1f}ms (limit 500ms)"
937
938 async def test_search_page_under_500ms(
939 self, client: AsyncClient, db_session: AsyncSession
940 ) -> None:
941 await client.get("/search")
942 start = time.perf_counter()
943 resp = await client.get("/search", params={"q": "test"})
944 elapsed = time.perf_counter() - start
945 assert resp.status_code == 200
946 assert elapsed < 0.500, f"Search page took {elapsed*1000:.1f}ms (limit 500ms)"
947
948 def test_to_camel_via_htmx_helpers_import_fast(self) -> None:
949 """Importing htmx_helpers is fast (module already loaded)."""
950 start = time.perf_counter()
951 from musehub.api.routes.musehub.htmx_helpers import is_htmx, htmx_trigger # noqa: F401
952 elapsed = time.perf_counter() - start
953 assert elapsed < 0.050, f"Import took {elapsed*1000:.1f}ms (limit 50ms)"
File History 1 commit
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff fix(tests): update test suite to match current implementation Sonnet 4.6 patch 120 days ago