test_musehub_ui_repo_home_ssr.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 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 | ) |