gabriel / musehub public
test_musehub_ui_repo_home_ssr.py python
217 lines 8.1 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """SSR tests for the repo home page clone URL.
2
3 Regression tests for two bugs:
4 1. Clone URL showed `muse remote add local musehub://owner/slug` — wrong command
5 and wrong scheme. The muse CLI does not understand musehub://.
6 2. Clone URL was hardcoded to musehub.ai regardless of which host served the
7 request, so staging.musehub.ai always showed the wrong URL.
8
9 Fixes verified here:
10 - GET /{owner}/{slug} renders `muse clone https://...` (not musehub://)
11 - Clone URL host matches the request host (not hardcoded musehub.ai)
12 - page_json block exposes `clone_url` as a valid https URL
13 """
14 from __future__ import annotations
15
16 import json
17
18 import pytest
19 from httpx import AsyncClient
20 from sqlalchemy.ext.asyncio import AsyncSession
21
22 from musehub.db.musehub_models import MusehubRepo
23
24
25 # ---------------------------------------------------------------------------
26 # Helpers
27 # ---------------------------------------------------------------------------
28
29
30 async def _make_repo(
31 db: AsyncSession,
32 owner: str = "gabriel",
33 slug: str = "musehub",
34 ) -> MusehubRepo:
35 repo = MusehubRepo(
36 name=slug,
37 owner=owner,
38 slug=slug,
39 visibility="public",
40 owner_user_id="uid-gabriel",
41 )
42 db.add(repo)
43 await db.commit()
44 await db.refresh(repo)
45 return repo
46
47
48 # ---------------------------------------------------------------------------
49 # Clone URL tests — Bug 1: wrong command and scheme
50 # ---------------------------------------------------------------------------
51
52
53 async def test_repo_home_clone_url_uses_muse_clone_not_remote_add(
54 client: AsyncClient,
55 db_session: AsyncSession,
56 ) -> None:
57 """Repo home page must show `muse clone <url>`, not `muse remote add local musehub://`."""
58 await _make_repo(db_session, owner="alice", slug="my-repo")
59 resp = await client.get("/alice/my-repo")
60 assert resp.status_code == 200
61 assert "muse clone" in resp.text, "Sidebar must show 'muse clone <url>'"
62 assert "muse remote add" not in resp.text, (
63 "muse remote add is not the clone command — remove it from the sidebar"
64 )
65
66
67 async def test_repo_home_clone_url_has_no_musehub_scheme(
68 client: AsyncClient,
69 db_session: AsyncSession,
70 ) -> None:
71 """Clone URL in the repo page must NOT use the musehub:// scheme.
72
73 The muse CLI does not resolve musehub:// — it only handles http(s)://.
74 A user copying this URL and running `muse clone musehub://...` will get
75 a transport error.
76 """
77 await _make_repo(db_session, owner="bob", slug="another-repo")
78 resp = await client.get("/bob/another-repo")
79 assert resp.status_code == 200
80 assert "musehub://" not in resp.text, (
81 "musehub:// is not a valid muse CLI scheme — remove it from the page"
82 )
83
84
85 async def test_repo_home_clone_url_uses_request_host(
86 client: AsyncClient,
87 db_session: AsyncSession,
88 ) -> None:
89 """Clone URL uses the actual request host, not a hardcoded musehub.ai.
90
91 On staging.musehub.ai the clone URL must be https://staging.musehub.ai/...
92 not https://musehub.ai/... This test uses the test client's base_url
93 (http://test) and verifies the clone URL contains that host.
94 """
95 await _make_repo(db_session, owner="carol", slug="test-repo")
96 resp = await client.get("/carol/test-repo")
97 assert resp.status_code == 200
98 # The httpx test client base_url is http://test — clone URL must reflect that.
99 assert "http://test" in resp.text, (
100 "Clone URL must be built from request.base_url, not hardcoded to musehub.ai"
101 )
102 assert "musehub.ai" not in resp.text or "staging.musehub.ai" not in resp.text, (
103 "Clone URL must not hardcode musehub.ai when served from a different host"
104 )
105
106
107 async def test_repo_home_page_json_clone_url_is_valid_https(
108 client: AsyncClient,
109 db_session: AsyncSession,
110 ) -> None:
111 """The page_json block exposes clone_url as a valid http(s):// URL.
112
113 The TypeScript initialiser reads clone_url from page_json to populate the
114 clone input. It must be a URL the muse CLI can use directly.
115 """
116 await _make_repo(db_session, owner="dave", slug="json-repo")
117 resp = await client.get("/dave/json-repo")
118 assert resp.status_code == 200
119
120 # Extract the page_json block content
121 text = resp.text
122 start = text.find('id="page-data"')
123 assert start != -1, "page-data script block not found"
124 # Find the content between the script tags
125 content_start = text.find(">", start) + 1
126 content_end = text.find("</script>", content_start)
127 page_json_raw = text[content_start:content_end].strip()
128 data = json.loads(page_json_raw)
129
130 clone_url = data.get("clone_url", "")
131 assert clone_url, "page_json must include clone_url"
132 assert clone_url.startswith("http"), (
133 f"clone_url must be http(s)://, got: {clone_url!r}"
134 )
135 assert "musehub://" not in clone_url
136 assert "dave" in clone_url
137 assert "json-repo" in clone_url
138
139
140 # ---------------------------------------------------------------------------
141 # Host allowlist test — spoofed Host header falls back to public_url
142 # ---------------------------------------------------------------------------
143
144
145 async def test_repo_home_clone_url_rejects_spoofed_host(
146 db_session: AsyncSession,
147 ) -> None:
148 """A spoofed Host header must NOT appear in the clone URL.
149
150 An attacker who can set an arbitrary Host header (e.g. evil.com) must not
151 be able to make the server render a clone URL pointing at their domain.
152 The route validates the host against settings.allowed_hosts and falls back
153 to settings.public_url when the host is not on the allowlist.
154 """
155 from httpx import AsyncClient, ASGITransport
156 from musehub.main import app
157
158 await _make_repo(db_session, owner="eve", slug="evil-test")
159
160 async with AsyncClient(
161 transport=ASGITransport(app=app),
162 base_url="http://evil.com",
163 headers={"Host": "evil.com"},
164 ) as evil_client:
165 resp = await evil_client.get("/eve/evil-test")
166
167 assert resp.status_code == 200
168 # The clone input value must not reflect the spoofed host.
169 # (evil.com may legitimately appear in oEmbed/og tags that encode the request URL.)
170 assert 'value="muse clone http://evil.com' not in resp.text, (
171 "Spoofed Host header must not appear in the clone input value"
172 )
173
174
175 # ---------------------------------------------------------------------------
176 # nginx config test — Bug 2: fetch endpoint missing from long-timeout block
177 # ---------------------------------------------------------------------------
178
179
180 def test_nginx_config_fetch_endpoints_have_long_timeout() -> None:
181 """nginx-cf.conf must give /fetch and /fetch/objects the same 300s timeout as /push.
182
183 Without this, cloning a large repo times out at 60s (the default location /
184 timeout), producing an HTTP 504 that the muse CLI surfaces as
185 '❌ Fetch objects failed: HTTP 504'.
186 """
187 import pathlib
188 nginx_conf = pathlib.Path(__file__).parent.parent / "deploy" / "nginx-cf.conf"
189 assert nginx_conf.exists(), f"nginx config not found at {nginx_conf}"
190 text = nginx_conf.read_text()
191
192 # Must have a location block covering the fetch endpoints
193 assert "fetch" in text, "nginx config must have a location block for fetch endpoints"
194
195 # The fetch block must have a 300s (or longer) timeout, not just 60s
196 lines = text.splitlines()
197 in_fetch_block = False
198 fetch_block_timeout: str | None = None
199 for line in lines:
200 stripped = line.strip()
201 if "fetch" in stripped and "location" in stripped:
202 in_fetch_block = True
203 if in_fetch_block and "proxy_read_timeout" in stripped:
204 fetch_block_timeout = stripped
205 break
206 if in_fetch_block and stripped == "}":
207 in_fetch_block = False
208
209 assert fetch_block_timeout is not None, (
210 "No proxy_read_timeout found in the fetch location block. "
211 "Add: proxy_read_timeout 300s; to the fetch location block."
212 )
213 # Extract seconds value
214 timeout_val = fetch_block_timeout.split()[-1].rstrip(";").rstrip("s")
215 assert int(timeout_val) >= 300, (
216 f"fetch location block timeout must be ≥300s, got {timeout_val}s"
217 )
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago