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