gabriel / musehub public
test_musehub_ui_repo_home_ssr.py python
224 lines 8.4 KB
Raw
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 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 13 commits
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 11 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 35 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago