gabriel / muse public

test_hub_api_ssl_and_public_access.py file-level

at sha256:d · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:b adding issues docs to bust staging mpack prebuild cache. · gabriel · Jun 20, 2026
1 """TDD tests for two _hub_api hardening fixes.
2
3 Bug A β€” localhost SSL
4 ---------------------
5 ``_hub_api`` uses bare ``urllib.request.urlopen`` without an SSL context.
6 For ``https://localhost:1337`` (self-signed cert), this raises
7 ``CERTIFICATE_VERIFY_FAILED`` even though the cert IS trusted by muse
8 (the same CA cert that ``muse push`` loads via ``transport._httpx_verify``
9 is stored at ``musehub/deploy/local-tls/localhost.crt``).
10
11 Expected: ``_hub_api`` must build an SSL context that loads the local CA
12 cert for localhost URLs, mirroring ``transport._httpx_verify``.
13
14 Bug B β€” unauthenticated GET on public resources
15 -----------------------------------------------
16 ``_hub_api`` exits non-zero when no signing identity is found, even for
17 GET requests on public repos (e.g. reading issues on a public MuseHub repo).
18
19 Expected: for GET requests, if no signing identity is available, proceed
20 without an ``Authorization`` header rather than exiting with an error.
21 Mutating requests (POST/PUT/DELETE/PATCH) still require auth.
22
23 Seven test tiers
24 ----------------
25 Unit β€” function-level mocks, no network
26 Contract β€” mock HTTP server, verify wire behaviour
27 Integration β€” CliRunner through the real command chain
28 Property β€” hypothesis: any localhost URL gets a non-default SSL context
29 Regression β€” previously failing cases pinned as non-regressions
30 Security β€” auth header absent on public GET; present on mutating requests
31 Stress β€” 8 threads calling _hub_api concurrently, no deadlock
32 """
33
34 from __future__ import annotations
35
36 import io
37 import json
38 import ssl
39 import threading
40 import urllib.error
41 import urllib.request
42 from typing import TYPE_CHECKING
43 from unittest.mock import MagicMock, call, patch
44
45 import pytest
46 from hypothesis import given, settings
47 from hypothesis import strategies as st
48
49 from muse.core.types import fake_id
50 from tests.cli_test_helper import CliRunner
51
52 type _IdentityDict = dict[str, str]
53
54 if TYPE_CHECKING:
55 pass
56
57 runner = CliRunner()
58
59 # ---------------------------------------------------------------------------
60 # Shared helpers
61 # ---------------------------------------------------------------------------
62
63 _IDENTITY: _IdentityDict = {"type": "human", "handle": "gabriel"}
64 _HUB_LOCAL = "https://localhost:1337"
65 _HUB_REMOTE = "https://staging.musehub.ai"
66
67
68 def _mock_resp(body: bytes = b'{"ok": true}') -> MagicMock:
69 resp = MagicMock()
70 resp.__enter__ = lambda s: s
71 resp.__exit__ = MagicMock(return_value=False)
72 resp.read.return_value = body
73 return resp
74
75
76 def _make_signing() -> MagicMock:
77 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
78 key = MagicMock(spec=Ed25519PrivateKey)
79 key.sign.return_value = b"\x00" * 64
80 key.public_key.return_value.public_bytes.return_value = b"\x00" * 32
81 from muse.core.transport import SigningIdentity
82 return SigningIdentity(handle="gabriel", private_key=key)
83
84
85 # ===========================================================================
86 # Tier 1 β€” Unit
87 # ===========================================================================
88
89 class TestHubApiSslContextUnit:
90 """_hub_api selects the correct SSL context for each host."""
91
92 def test_localhost_https_gets_custom_ssl_context(self) -> None:
93 """Bug A: localhost HTTPS must use local CA cert, not system store."""
94 from muse.cli.commands.hub._core import _hub_api
95
96 captured_kwargs: list[dict] = []
97
98 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
99 captured_kwargs.append({"context": context})
100 return _mock_resp()
101
102 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
103 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
104 _hub_api(_HUB_LOCAL, _IDENTITY, "GET", "/api/test")
105
106 assert captured_kwargs, "urlopen was not called"
107 ctx = captured_kwargs[0]["context"]
108 assert ctx is not None, (
109 "_hub_api passed context=None to urlopen for localhost β€” "
110 "self-signed cert will be rejected by the OS CA store"
111 )
112 assert isinstance(ctx, ssl.SSLContext), (
113 f"Expected ssl.SSLContext, got {type(ctx)}"
114 )
115
116 def test_remote_https_uses_system_ca(self) -> None:
117 """Non-localhost HTTPS must use the system CA store (context=None)."""
118 from muse.cli.commands.hub._core import _hub_api
119
120 captured_kwargs: list[dict] = []
121
122 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
123 captured_kwargs.append({"context": context})
124 return _mock_resp()
125
126 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
127 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
128 _hub_api(_HUB_REMOTE, _IDENTITY, "GET", "/api/test")
129
130 assert captured_kwargs
131 ctx = captured_kwargs[0]["context"]
132 assert ctx is None, (
133 "Non-localhost hub must use system CA (context=None), "
134 f"but got {ctx!r}"
135 )
136
137 def test_127_0_0_1_https_gets_custom_ssl_context(self) -> None:
138 """127.0.0.1 is loopback β€” also gets the local CA cert context."""
139 from muse.cli.commands.hub._core import _hub_api
140
141 captured_kwargs: list[dict] = []
142
143 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
144 captured_kwargs.append({"context": context})
145 return _mock_resp()
146
147 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
148 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
149 _hub_api("https://127.0.0.1:1337", _IDENTITY, "GET", "/api/test")
150
151 ctx = captured_kwargs[0]["context"]
152 assert ctx is not None and isinstance(ctx, ssl.SSLContext)
153
154
155 class TestHubApiPublicGetUnit:
156 """Bug B: unauthenticated GET on public endpoint proceeds without auth."""
157
158 def test_get_without_signing_proceeds(self) -> None:
159 """No signing identity β†’ GET proceeds without Authorization header."""
160 from muse.cli.commands.hub._core import _hub_api
161
162 captured_headers: list[dict] = []
163
164 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
165 captured_headers.append(dict(req.headers))
166 return _mock_resp()
167
168 with patch("muse.cli.config.get_signing_identity", return_value=None):
169 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
170 result = _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/test")
171
172 assert result == {"ok": True}
173 assert captured_headers, "urlopen was never called"
174 assert "Authorization" not in captured_headers[0], (
175 "Authorization header must not be sent when no signing identity is available"
176 )
177
178 def test_get_without_signing_does_not_exit(self) -> None:
179 """Bug B: no signing identity on GET must NOT raise SystemExit."""
180 from muse.cli.commands.hub._core import _hub_api
181
182 with patch("muse.cli.config.get_signing_identity", return_value=None):
183 with patch("urllib.request.urlopen", return_value=_mock_resp()):
184 # Must not raise SystemExit
185 result = _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/test")
186 assert isinstance(result, dict)
187
188 def test_post_without_signing_exits(self) -> None:
189 """POST without signing identity must still exit β€” mutating ops need auth."""
190 from muse.cli.commands.hub._core import _hub_api
191
192 with patch("muse.cli.config.get_signing_identity", return_value=None):
193 with patch("urllib.request.urlopen") as mock_net:
194 with pytest.raises(SystemExit):
195 _hub_api("http://localhost:9999", _IDENTITY, "POST", "/api/test",
196 body={"key": "val"})
197 mock_net.assert_not_called()
198
199 def test_delete_without_signing_exits(self) -> None:
200 """DELETE without signing must exit."""
201 from muse.cli.commands.hub._core import _hub_api
202
203 with patch("muse.cli.config.get_signing_identity", return_value=None):
204 with patch("urllib.request.urlopen") as mock_net:
205 with pytest.raises(SystemExit):
206 _hub_api("http://localhost:9999", _IDENTITY, "DELETE", "/api/test")
207 mock_net.assert_not_called()
208
209 def test_get_with_signing_includes_auth_header(self) -> None:
210 """When signing IS available, GET requests include Authorization."""
211 from muse.cli.commands.hub._core import _hub_api
212
213 captured_headers: list[dict] = []
214
215 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
216 captured_headers.append(dict(req.headers))
217 return _mock_resp()
218
219 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
220 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
221 _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/test")
222
223 assert "Authorization" in captured_headers[0], (
224 "Authorization header must be included when signing identity is available"
225 )
226
227
228 # ===========================================================================
229 # Tier 2 β€” Contract (mock HTTP server verifying wire behaviour)
230 # ===========================================================================
231
232 class TestHubApiContract:
233 """Verify wire-level behaviour through a mock HTTP handler."""
234
235 def test_public_get_sends_no_auth_header(self) -> None:
236 """Wire: GET to public endpoint carries no Authorization field."""
237 from muse.cli.commands.hub._core import _hub_api
238
239 received: list[dict] = []
240
241 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
242 received.append({"headers": dict(req.headers), "method": req.get_method()})
243 return _mock_resp(b'{"number": 5, "title": "muse bridge"}')
244
245 with patch("muse.cli.config.get_signing_identity", return_value=None):
246 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
247 result = _hub_api("http://localhost:9999", _IDENTITY, "GET", "/gabriel/musehub/issues/5")
248
249 assert received[0]["method"] == "GET"
250 assert "Authorization" not in received[0]["headers"]
251 assert result.get("number") == 5
252
253 def test_authenticated_get_sends_msign_header(self) -> None:
254 """Wire: GET with identity carries MSign Authorization header."""
255 from muse.cli.commands.hub._core import _hub_api
256
257 received: list[dict] = []
258
259 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
260 received.append({"headers": dict(req.headers)})
261 return _mock_resp()
262
263 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
264 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
265 _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/test")
266
267 auth = received[0]["headers"].get("Authorization", "")
268 assert auth.startswith("MSign "), f"Expected MSign header, got: {auth!r}"
269
270 def test_localhost_ssl_context_cafile_set(self) -> None:
271 """Wire: SSL context passed to urlopen for localhost loads the CA file."""
272 from muse.cli.commands.hub._core import _hub_api
273 import pathlib
274
275 local_cert = (
276 pathlib.Path(__file__).parent.parent.parent
277 / "musehub" / "deploy" / "local-tls" / "localhost.crt"
278 )
279
280 captured_ctx: list[ssl.SSLContext | None] = []
281
282 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
283 captured_ctx.append(context)
284 return _mock_resp()
285
286 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
287 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
288 _hub_api(_HUB_LOCAL, _IDENTITY, "GET", "/api/test")
289
290 ctx = captured_ctx[0]
291 assert ctx is not None
292 # The context must have the local cert loaded β€” verify it accepts localhost
293 if local_cert.exists():
294 # Build the same context directly and compare verify_mode
295 expected = ssl.create_default_context(cafile=str(local_cert))
296 assert ctx.verify_mode == expected.verify_mode
297
298
299 # ===========================================================================
300 # Tier 3 β€” Integration (CliRunner through real command chain)
301 # ===========================================================================
302
303 class TestHubIssueReadPublicIntegration:
304 """muse hub issue read on a public repo works without a signing identity."""
305
306 _FAKE_REPO_ID = fake_id("repo")
307
308 def test_issue_read_public_no_auth_succeeds(self) -> None:
309 """Integration: hub issue read proceeds without signing identity."""
310 issue_body = json.dumps({
311 "number": 5,
312 "title": "muse bridge",
313 "body": "Implement muse bridge for git interop",
314 "state": "open",
315 "labels": [],
316 }).encode()
317
318 with patch("muse.cli.config.get_signing_identity", return_value=None):
319 with patch(
320 "muse.cli.commands.hub._resolve_repo_id",
321 return_value=self._FAKE_REPO_ID,
322 ):
323 with patch("urllib.request.urlopen", return_value=_mock_resp(issue_body)):
324 result = runner.invoke(
325 None,
326 ["hub", "issue", "read", "5",
327 "--hub", "http://localhost:9999/gabriel/musehub",
328 "--json"],
329 )
330
331 assert result.exit_code == 0, (
332 f"exit_code={result.exit_code}\n{result.output}\n{result.stderr}"
333 )
334 data = json.loads(result.output)
335 assert data.get("number") == 5 or "title" in data or data.get("exit_code") == 0
336
337 def test_issue_list_public_no_auth_succeeds(self) -> None:
338 """Integration: hub issue list on public repo proceeds without auth."""
339 issues_body = json.dumps({
340 "issues": [{"number": 5, "title": "muse bridge", "state": "open", "labels": []}],
341 "total": 1,
342 }).encode()
343
344 with patch("muse.cli.config.get_signing_identity", return_value=None):
345 with patch(
346 "muse.cli.commands.hub._resolve_repo_id",
347 return_value=self._FAKE_REPO_ID,
348 ):
349 with patch("urllib.request.urlopen", return_value=_mock_resp(issues_body)):
350 result = runner.invoke(
351 None,
352 ["hub", "issue", "list",
353 "--hub", "http://localhost:9999/gabriel/musehub",
354 "--json"],
355 )
356
357 # Must not fail with "not authenticated" error
358 assert "signing" not in result.stderr.lower()
359 assert "not authenticated" not in result.stderr.lower()
360
361
362 # ===========================================================================
363 # Tier 4 β€” Property (hypothesis)
364 # ===========================================================================
365
366 class TestHubApiSslContextProperty:
367 """Property: any localhost/127 URL produces a non-None SSL context."""
368
369 @given(
370 port=st.integers(min_value=1024, max_value=65535),
371 path=st.text(
372 alphabet=st.characters(whitelist_categories=("Lu", "Ll", "Nd"), whitelist_characters="/-_"),
373 min_size=1, max_size=40,
374 ),
375 )
376 @settings(max_examples=20, deadline=2000)
377 def test_localhost_always_gets_custom_context(self, port: int, path: str) -> None:
378 """Any https://localhost:<port><path> must produce a non-None SSL context."""
379 from muse.cli.commands.hub._core import _hub_api
380
381 captured: list[ssl.SSLContext | None] = []
382
383 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
384 captured.append(context)
385 return _mock_resp()
386
387 url = f"https://localhost:{port}"
388 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
389 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
390 try:
391 _hub_api(url, _IDENTITY, "GET", f"/{path.lstrip('/')}")
392 except SystemExit:
393 pass # scheme or URL validation may exit β€” that's fine
394
395 if captured:
396 assert captured[0] is not None, (
397 f"localhost:{port} got None SSL context β€” self-signed cert will be rejected"
398 )
399
400 @given(method=st.sampled_from(["POST", "PUT", "DELETE", "PATCH"]))
401 @settings(max_examples=10, deadline=2000)
402 def test_mutating_methods_require_auth(self, method: str) -> None:
403 """Property: mutating HTTP methods always require a signing identity."""
404 from muse.cli.commands.hub._core import _hub_api
405
406 with patch("muse.cli.config.get_signing_identity", return_value=None):
407 with patch("urllib.request.urlopen") as mock_net:
408 with pytest.raises(SystemExit):
409 _hub_api("http://localhost:9999", _IDENTITY, method, "/api/test",
410 body={"x": "y"} if method != "DELETE" else None)
411 mock_net.assert_not_called()
412
413
414 # ===========================================================================
415 # Tier 5 β€” Regression (pinned non-regressions)
416 # ===========================================================================
417
418 class TestHubApiRegression:
419 """Pinned regressions: previously broken cases must stay fixed."""
420
421 def test_R1_hub_issue_read_no_ssl_error_on_localhost(self) -> None:
422 """R1: Reading an issue from https://localhost must not raise SSL error."""
423 from muse.cli.commands.hub._core import _hub_api
424
425 ssl_errors: list[Exception] = []
426
427 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
428 # Simulate what happens WITHOUT a proper context β€” verify the fix
429 # prevents this from reaching urlopen with the system CA store.
430 if context is None:
431 ssl_errors.append(
432 ssl.SSLCertVerificationError("CERTIFICATE_VERIFY_FAILED")
433 )
434 return _mock_resp(b'{"number": 5}')
435
436 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
437 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
438 _hub_api(_HUB_LOCAL, _IDENTITY, "GET", "/gabriel/musehub/issues/5")
439
440 assert not ssl_errors, (
441 f"R1 regression: _hub_api passed context=None to urlopen on localhost, "
442 f"which would cause CERTIFICATE_VERIFY_FAILED: {ssl_errors}"
443 )
444
445 def test_R2_public_issue_read_no_exit_without_identity(self) -> None:
446 """R2: hub issue read on public repo must NOT exit 1 when no identity set."""
447 from muse.cli.commands.hub._core import _hub_api
448
449 with patch("muse.cli.config.get_signing_identity", return_value=None):
450 with patch("urllib.request.urlopen", return_value=_mock_resp(b'{"number": 5}')):
451 # Must not raise SystemExit
452 result = _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/issues/5")
453 assert result == {"number": 5}
454
455 def test_R3_file_scheme_still_blocked(self) -> None:
456 """R3: file:// scheme is still blocked regardless of signing identity."""
457 from muse.cli.commands.hub._core import _hub_api
458
459 with patch("urllib.request.urlopen") as mock_net:
460 with pytest.raises(SystemExit):
461 _hub_api("file:///etc/passwd", _IDENTITY, "GET", "/test")
462 mock_net.assert_not_called()
463
464 def test_R4_response_size_cap_still_enforced(self) -> None:
465 """R4: size cap still applies on unauthenticated GET responses."""
466 from muse.cli.commands.hub._core import _hub_api, _MAX_API_RESPONSE_BYTES
467
468 big_resp = _mock_resp(b"x" * (_MAX_API_RESPONSE_BYTES + 2))
469
470 with patch("muse.cli.config.get_signing_identity", return_value=None):
471 with patch("urllib.request.urlopen", return_value=big_resp):
472 with pytest.raises(SystemExit):
473 _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/test")
474
475 def test_R5_existing_auth_tests_unaffected(self) -> None:
476 """R5: authenticated calls continue to include MSign header after the fix."""
477 from muse.cli.commands.hub._core import _hub_api
478
479 headers_seen: list[dict] = []
480
481 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
482 headers_seen.append(dict(req.headers))
483 return _mock_resp()
484
485 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
486 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
487 _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/test")
488
489 assert headers_seen[0].get("Authorization", "").startswith("MSign ")
490
491
492 # ===========================================================================
493 # Tier 6 β€” Security
494 # ===========================================================================
495
496 class TestHubApiSecurity:
497 """Security invariants for the two fixes."""
498
499 def test_no_auth_header_leaked_on_unauthenticated_get(self) -> None:
500 """Auth header must be completely absent β€” not empty β€” on public GET."""
501 from muse.cli.commands.hub._core import _hub_api
502
503 headers_seen: list[dict] = []
504
505 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
506 headers_seen.append(dict(req.headers))
507 return _mock_resp()
508
509 with patch("muse.cli.config.get_signing_identity", return_value=None):
510 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
511 _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/test")
512
513 h = headers_seen[0]
514 assert "Authorization" not in h
515 assert "authorization" not in {k.lower() for k in h}
516
517 def test_ansi_in_http_error_body_still_sanitized(
518 self, capsys: pytest.CaptureFixture[str]
519 ) -> None:
520 """ANSI sequences in error bodies are sanitized on unauthenticated errors."""
521 from muse.cli.commands.hub._core import _hub_api
522
523 ansi_body = b'{"detail": "\\x1b[31merror\\x1b[0m"}'
524 exc = urllib.error.HTTPError(
525 url="", code=401, msg="Unauthorized",
526 hdrs=MagicMock(), fp=io.BytesIO(ansi_body),
527 )
528 with patch("muse.cli.config.get_signing_identity", return_value=None):
529 with patch("urllib.request.urlopen", side_effect=exc):
530 with pytest.raises(SystemExit):
531 _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/test")
532
533 captured = capsys.readouterr()
534 assert "\x1b[" not in captured.err
535
536 def test_ssl_context_does_not_disable_verification(self) -> None:
537 """The custom SSL context must not set CERT_NONE β€” it loads a CA, not disables checking."""
538 from muse.cli.commands.hub._core import _hub_api
539
540 captured_ctx: list[ssl.SSLContext | None] = []
541
542 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
543 captured_ctx.append(context)
544 return _mock_resp()
545
546 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
547 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
548 _hub_api(_HUB_LOCAL, _IDENTITY, "GET", "/api/test")
549
550 ctx = captured_ctx[0]
551 if ctx is not None:
552 assert ctx.verify_mode != ssl.CERT_NONE, (
553 "SSL context must not disable verification β€” "
554 "use a CA cert file, not ssl.CERT_NONE"
555 )
556
557 def test_ssrf_scheme_blocking_unaffected_by_public_get_change(self) -> None:
558 """The public-GET change must not relax SSRF scheme blocking."""
559 from muse.cli.commands.hub._core import _hub_api
560
561 for scheme in ("file", "ftp", "javascript", "data"):
562 with patch("urllib.request.urlopen") as mock_net:
563 with pytest.raises(SystemExit):
564 _hub_api(f"{scheme}://evil.example.com", _IDENTITY, "GET", "/api/test")
565 mock_net.assert_not_called()
566
567
568 # ===========================================================================
569 # Tier 7 β€” Stress
570 # ===========================================================================
571
572 class TestHubApiStress:
573 """Concurrent calls to _hub_api must not deadlock or corrupt state."""
574
575 def test_concurrent_unauthenticated_gets_no_deadlock(self) -> None:
576 """8 threads calling _hub_api(GET, no auth) concurrently β€” no deadlock."""
577 from muse.cli.commands.hub._core import _hub_api
578
579 results: list[dict | Exception] = []
580 lock = threading.Lock()
581
582 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
583 return _mock_resp(b'{"ok": true}')
584
585 def worker() -> None:
586 try:
587 with patch("muse.cli.config.get_signing_identity", return_value=None):
588 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
589 r = _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/test")
590 with lock:
591 results.append(r)
592 except Exception as exc:
593 with lock:
594 results.append(exc)
595
596 threads = [threading.Thread(target=worker) for _ in range(8)]
597 for t in threads:
598 t.start()
599 for t in threads:
600 t.join(timeout=5)
601
602 assert len(results) == 8, f"Only {len(results)}/8 threads completed"
603 errors = [r for r in results if isinstance(r, Exception)]
604 assert not errors, f"Thread errors: {errors}"
605
606 def test_concurrent_authenticated_gets_no_deadlock(self) -> None:
607 """8 threads calling _hub_api(GET, with auth) concurrently β€” no deadlock."""
608 from muse.cli.commands.hub._core import _hub_api
609
610 results: list[dict | Exception] = []
611 lock = threading.Lock()
612
613 def fake_urlopen(req: urllib.request.Request, timeout: float = 10, context: ssl.SSLContext | None = None) -> MagicMock:
614 return _mock_resp(b'{"ok": true}')
615
616 def worker() -> None:
617 try:
618 with patch("muse.cli.config.get_signing_identity", return_value=_make_signing()):
619 with patch("urllib.request.urlopen", side_effect=fake_urlopen):
620 r = _hub_api("http://localhost:9999", _IDENTITY, "GET", "/api/test")
621 with lock:
622 results.append(r)
623 except Exception as exc:
624 with lock:
625 results.append(exc)
626
627 threads = [threading.Thread(target=worker) for _ in range(8)]
628 for t in threads:
629 t.start()
630 for t in threads:
631 t.join(timeout=5)
632
633 assert len(results) == 8
634 errors = [r for r in results if isinstance(r, Exception)]
635 assert not errors, f"Thread errors: {errors}"