gabriel / muse public
test_stress_domains_publish.py python
731 lines 29.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Stress and integration tests for ``muse domains publish``.
2
3 Covers:
4 Stress:
5 - 500 sequential publish calls with mocked HTTP (throughput baseline)
6 - Concurrent mock publishes via threading (thread-safety of CliRunner)
7 - Large capabilities JSON (100 dimensions, 50 artifact types)
8 - Rapid successive 409 conflicts do not leak state
9
10 Integration (end-to-end CLI):
11 - Full workflow: scaffold → register → publish (all mock layers wired)
12 - Round-trip: publish → parse --json → assert all fields present
13 - Author/slug with hyphens and underscores (URL-safe validation)
14 - Unicode description does not corrupt JSON encoding
15 - Very long description (4000 chars) is transmitted verbatim
16 - Hub URL override propagates to HTTP request
17 """
18 from __future__ import annotations
19
20 import concurrent.futures
21 import http.client
22 import io
23 import json
24 import pathlib
25 import threading
26 import time
27 import urllib.error
28 import urllib.request
29 import unittest.mock
30 from typing import Generator
31
32 import pytest
33 from tests.cli_test_helper import CliRunner
34
35 from muse._version import __version__
36 cli = None # argparse migration — CliRunner ignores this arg
37 from muse.cli.commands.domains import _post_json, _PublishPayload, _Capabilities, _DimensionDef
38
39 runner = CliRunner()
40
41
42 def _make_signing() -> "SigningIdentity":
43 """Generate a fresh Ed25519 SigningIdentity for tests."""
44 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
45 from muse.core.transport import SigningIdentity
46 return SigningIdentity(handle="testuser", private_key=Ed25519PrivateKey.generate())
47
48
49 # ---------------------------------------------------------------------------
50 # Fixtures
51 # ---------------------------------------------------------------------------
52
53 _REQUIRED_ARGS = [
54 "domains", "publish",
55 "--author", "alice",
56 "--slug", "genomics",
57 "--name", "Genomics",
58 "--description", "Version DNA sequences",
59 "--viewer-type", "genome",
60 "--capabilities", json.dumps({
61 "dimensions": [{"name": "sequence", "description": "DNA base pairs"}],
62 "artifact_types": ["fasta"],
63 "merge_semantics": "three_way",
64 "supported_commands": ["commit", "diff"],
65 }),
66 "--hub", "https://hub.test",
67 ]
68
69 _SUCCESS_BODY = json.dumps({
70 "domain_id": "dom-001",
71 "scoped_id": "@alice/genomics",
72 "manifest_hash": "sha256:abc123",
73 })
74
75
76 @pytest.fixture()
77 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
78 muse_dir = tmp_path / ".muse"
79 (muse_dir / "refs" / "heads").mkdir(parents=True)
80 (muse_dir / "objects").mkdir()
81 (muse_dir / "commits").mkdir()
82 (muse_dir / "snapshots").mkdir()
83 (muse_dir / "repo.json").write_text(
84 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
85 )
86 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
87 monkeypatch.chdir(tmp_path)
88 monkeypatch.setattr("muse.cli.commands.domains.get_signing_identity", lambda *a, **kw: _make_signing())
89 return tmp_path
90
91
92 def _mock_ok() -> unittest.mock.MagicMock:
93 """Return a context-manager mock that yields a 200 response."""
94 mock_resp = unittest.mock.MagicMock()
95 mock_resp.read.return_value = _SUCCESS_BODY.encode()
96 mock_resp.__enter__ = unittest.mock.MagicMock(return_value=mock_resp)
97 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
98 return mock_resp
99
100
101 # ---------------------------------------------------------------------------
102 # _post_json unit stress
103 # ---------------------------------------------------------------------------
104
105
106 def test_post_json_sequential_throughput() -> None:
107 """500 sequential _post_json calls complete in under 2 seconds (mock)."""
108 payload = _PublishPayload(
109 author_slug="alice",
110 slug="bench",
111 display_name="Bench",
112 description="Benchmark domain",
113 capabilities=_Capabilities(
114 dimensions=[_DimensionDef(name="x", description="x axis")],
115 merge_semantics="three_way",
116 ),
117 viewer_type="spatial",
118 version="0.1.0",
119 )
120
121 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
122 start = time.monotonic()
123 for _ in range(500):
124 result = _post_json("https://hub.test/api/v1/domains", payload, _make_signing())
125 assert result["scoped_id"] == "@alice/genomics"
126 elapsed = time.monotonic() - start
127
128 assert elapsed < 2.0, f"500 iterations took {elapsed:.2f}s — too slow"
129
130
131 def test_post_json_large_capabilities() -> None:
132 """_post_json handles 100-dimension, 50-artifact-type payloads without truncation."""
133 dims = [_DimensionDef(name=f"dim_{i}", description=f"Dimension {i} " * 10) for i in range(100)]
134 artifacts = [f"type_{i:03}" for i in range(50)]
135 payload = _PublishPayload(
136 author_slug="alice",
137 slug="large",
138 display_name="Large Domain",
139 description="A domain with many dimensions",
140 capabilities=_Capabilities(
141 dimensions=dims,
142 artifact_types=artifacts,
143 merge_semantics="three_way",
144 supported_commands=["commit", "diff", "merge", "log", "status"],
145 ),
146 viewer_type="generic",
147 version="1.0.0",
148 )
149
150 captured_bodies: list[bytes] = []
151
152 def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
153 raw = req.data
154 captured_bodies.append(raw if raw is not None else b"")
155 return _mock_ok()
156
157 with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture):
158 result = _post_json("https://hub.test/api/v1/domains", payload, _make_signing())
159
160 body = json.loads(captured_bodies[0])
161 assert len(body["capabilities"]["dimensions"]) == 100
162 assert len(body["capabilities"]["artifact_types"]) == 50
163 assert result["scoped_id"] == "@alice/genomics"
164
165
166 def test_post_json_unicode_description() -> None:
167 """Unicode characters in description survive JSON round-trip correctly."""
168 unicode_desc = "Version 🎵 séquences d'ADN — supports 漢字 and Ñoño input"
169 payload = _PublishPayload(
170 author_slug="alice",
171 slug="unicode",
172 display_name="Unicode Domain",
173 description=unicode_desc,
174 capabilities=_Capabilities(),
175 viewer_type="generic",
176 version="0.1.0",
177 )
178
179 captured_bodies: list[bytes] = []
180
181 def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
182 raw = req.data
183 captured_bodies.append(raw if raw is not None else b"")
184 return _mock_ok()
185
186 with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture):
187 _post_json("https://hub.test/api/v1/domains", payload, _make_signing())
188
189 body = json.loads(captured_bodies[0].decode("utf-8"))
190 assert body["description"] == unicode_desc
191
192
193 def test_post_json_409_does_not_modify_state() -> None:
194 """Multiple 409 errors in a row do not corrupt any shared state."""
195 payload = _PublishPayload(
196 author_slug="alice",
197 slug="conflict",
198 display_name="X",
199 description="Y",
200 capabilities=_Capabilities(),
201 viewer_type="v",
202 version="0.1.0",
203 )
204 err = urllib.error.HTTPError(
205 url="https://hub.test/api/v1/domains",
206 code=409,
207 msg="Conflict",
208 hdrs=http.client.HTTPMessage(),
209 fp=io.BytesIO(b'{"error": "already_exists"}'),
210 )
211 with unittest.mock.patch("urllib.request.urlopen", side_effect=err):
212 for _ in range(50):
213 with pytest.raises(urllib.error.HTTPError) as exc_info:
214 _post_json("https://hub.test/api/v1/domains", payload, _make_signing())
215 assert exc_info.value.code == 409
216
217
218 # ---------------------------------------------------------------------------
219 # CLI stress tests
220 # ---------------------------------------------------------------------------
221
222
223 def test_cli_publish_large_description(repo: pathlib.Path) -> None:
224 """CLI accepts --description up to 4000 characters and transmits verbatim."""
225 long_desc = "A" * 4000
226 large_args = [
227 "domains", "publish",
228 "--author", "alice",
229 "--slug", "largdesc",
230 "--name", "Large",
231 "--description", long_desc,
232 "--viewer-type", "genome",
233 "--capabilities", '{"merge_semantics": "three_way"}',
234 "--hub", "https://hub.test",
235 ]
236 captured: list[bytes] = []
237
238 def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
239 raw = req.data
240 captured.append(raw if raw is not None else b"")
241 return _mock_ok()
242
243 with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture):
244 result = runner.invoke(cli, large_args)
245
246 assert result.exit_code == 0, result.output
247 body = json.loads(captured[0])
248 assert body["description"] == long_desc
249
250
251 def test_cli_publish_slug_with_hyphens(repo: pathlib.Path) -> None:
252 """--slug with hyphens (e.g. 'spatial-3d') is transmitted as-is."""
253 args = [
254 "domains", "publish",
255 "--author", "alice",
256 "--slug", "spatial-3d",
257 "--name", "Spatial 3D",
258 "--description", "Version 3-D scenes",
259 "--viewer-type", "spatial",
260 "--capabilities", '{"merge_semantics": "three_way"}',
261 "--hub", "https://hub.test",
262 ]
263 captured: list[bytes] = []
264
265 def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
266 raw = req.data
267 captured.append(raw if raw is not None else b"")
268 return _mock_ok()
269
270 with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture):
271 result = runner.invoke(cli, args)
272
273 assert result.exit_code == 0, result.output
274 body = json.loads(captured[0])
275 assert body["slug"] == "spatial-3d"
276
277
278 def test_cli_publish_hub_url_propagated(repo: pathlib.Path) -> None:
279 """--hub URL override is used as the request endpoint."""
280 custom_hub = "https://custom.musehub.example.com"
281 captured_urls: list[str] = []
282
283 def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
284 captured_urls.append(req.full_url)
285 return _mock_ok()
286
287 with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture):
288 result = runner.invoke(cli, _REQUIRED_ARGS[:-2] + ["--hub", custom_hub])
289
290 assert result.exit_code == 0, result.output
291 assert captured_urls[0].startswith(custom_hub)
292
293
294 def test_cli_publish_json_roundtrip(repo: pathlib.Path) -> None:
295 """--json output is valid JSON with all expected keys."""
296 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
297 result = runner.invoke(cli, _REQUIRED_ARGS + ["--json"])
298
299 assert result.exit_code == 0, result.output
300 data = json.loads(result.output.strip())
301 assert "scoped_id" in data
302 assert "manifest_hash" in data
303
304
305 def test_cli_publish_version_semver(repo: pathlib.Path) -> None:
306 """--version is passed through without modification."""
307 captured: list[bytes] = []
308
309 def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
310 raw = req.data
311 captured.append(raw if raw is not None else b"")
312 return _mock_ok()
313
314 with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture):
315 result = runner.invoke(cli, _REQUIRED_ARGS + ["--version", "2.14.0-beta.1"])
316
317 assert result.exit_code == 0, result.output
318 assert json.loads(captured[0])["version"] == "2.14.0-beta.1"
319
320
321 # ---------------------------------------------------------------------------
322 # Thread safety
323 # ---------------------------------------------------------------------------
324
325
326 def test_post_json_concurrent_thread_safety() -> None:
327 """10 concurrent threads invoking _post_json do not race on mock state."""
328 # CliRunner is not thread-safe (StringIO), so we test the lower-level
329 # _post_json helper directly — this is what the CLI delegates to.
330 counter: list[int] = [0]
331 lock = threading.Lock()
332
333 def _count_and_ok(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
334 with lock:
335 counter[0] += 1
336 return _mock_ok()
337
338 payload = _PublishPayload(
339 author_slug="alice",
340 slug="genomics",
341 display_name="Genomics",
342 description="Version DNA",
343 capabilities=_Capabilities(merge_semantics="three_way"),
344 viewer_type="genome",
345 version="0.1.0",
346 )
347
348 with unittest.mock.patch("urllib.request.urlopen", side_effect=_count_and_ok):
349 with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
350 futures = [
351 pool.submit(_post_json, "https://hub.test/api/v1/domains", payload, _make_signing())
352 for _ in range(10)
353 ]
354 results = [f.result() for f in futures]
355
356 assert len(results) == 10
357 assert all(r["scoped_id"] == "@alice/genomics" for r in results)
358 assert counter[0] == 10
359
360
361 # ---------------------------------------------------------------------------
362 # End-to-end integration
363 # ---------------------------------------------------------------------------
364
365
366 def test_e2e_publish_complete_payload_structure(repo: pathlib.Path) -> None:
367 """E2E: full publish sends author_slug, slug, display_name, description, capabilities, viewer_type, version."""
368 captured: list[bytes] = []
369
370 def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
371 raw = req.data
372 captured.append(raw if raw is not None else b"")
373 return _mock_ok()
374
375 with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture):
376 result = runner.invoke(cli, _REQUIRED_ARGS + ["--version", "1.0.0"])
377
378 assert result.exit_code == 0, result.output
379 body = json.loads(captured[0])
380
381 # All required fields present
382 assert body["author_slug"] == "alice"
383 assert body["slug"] == "genomics"
384 assert body["display_name"] == "Genomics"
385 assert body["description"] == "Version DNA sequences"
386 assert body["viewer_type"] == "genome"
387 assert body["version"] == "1.0.0"
388
389 # Capabilities structure
390 caps = body["capabilities"]
391 assert isinstance(caps, dict)
392 assert "dimensions" in caps
393 assert isinstance(caps["dimensions"], list)
394
395
396 def test_e2e_publish_capabilities_auto_from_code_plugin(repo: pathlib.Path) -> None:
397 """E2E: capabilities auto-derived from code plugin contain correct dimensions."""
398 no_caps_args = [a for a in _REQUIRED_ARGS if a not in ("--capabilities",)]
399 # Remove the JSON value immediately after --capabilities
400 filtered: list[str] = []
401 skip_next = False
402 for arg in _REQUIRED_ARGS:
403 if skip_next:
404 skip_next = False
405 continue
406 if arg == "--capabilities":
407 skip_next = True
408 continue
409 filtered.append(arg)
410
411 captured: list[bytes] = []
412
413 def _capture(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
414 raw = req.data
415 captured.append(raw if raw is not None else b"")
416 return _mock_ok()
417
418 with unittest.mock.patch("urllib.request.urlopen", side_effect=_capture):
419 result = runner.invoke(cli, filtered)
420
421 assert result.exit_code == 0, result.output
422 body = json.loads(captured[0])
423 dims = body["capabilities"]["dimensions"]
424 # Code plugin has 5 dimensions — must include "symbols" and "imports"
425 names = [d["name"] for d in dims]
426 assert "symbols" in names
427 assert "imports" in names
428 assert len(dims) >= 3
429
430
431 def test_e2e_publish_400_sequential_calls_stable(repo: pathlib.Path) -> None:
432 """E2E stress: 400 sequential publish invocations all succeed.
433
434 The wall-clock budget is intentionally generous (120s) to accommodate
435 GitHub Actions' shared runners, which can be 3-4× slower than a
436 developer laptop. The assertion guards against catastrophic regressions
437 (infinite loops, exponential backoff bugs) rather than raw throughput.
438 """
439 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
440 start = time.monotonic()
441 for i in range(400):
442 result = runner.invoke(cli, _REQUIRED_ARGS)
443 assert result.exit_code == 0, f"Run {i} failed: {result.output}"
444 elapsed = time.monotonic() - start
445
446 assert elapsed < 120.0, f"400 CLI invocations took {elapsed:.1f}s"
447
448
449 # ---------------------------------------------------------------------------
450 # Extended — muse domains publish (deeper coverage)
451 # ---------------------------------------------------------------------------
452
453
454 class TestPublishExtended:
455 def test_j_alias_works(self, repo: pathlib.Path) -> None:
456 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
457 result = runner.invoke(cli, _REQUIRED_ARGS + ["-j"])
458 assert result.exit_code == 0, result.output
459 data = json.loads(result.output.strip())
460 assert "scoped_id" in data
461
462 def test_help_mentions_json_flag(self, repo: pathlib.Path) -> None:
463 result = runner.invoke(cli, ["domains", "publish", "--help"])
464 assert "--json" in result.output or "-j" in result.output
465
466 def test_help_shows_exit_codes(self, repo: pathlib.Path) -> None:
467 result = runner.invoke(cli, ["domains", "publish", "--help"])
468 assert "Exit code" in result.output or "exit code" in result.output
469
470 def test_json_is_compact_single_line(self, repo: pathlib.Path) -> None:
471 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
472 result = runner.invoke(cli, _REQUIRED_ARGS + ["--json"])
473 assert result.exit_code == 0, result.output
474 lines = [l for l in result.output.splitlines() if l.strip()]
475 assert len(lines) == 1, f"Expected 1 non-empty line, got {len(lines)}"
476
477 def test_json_has_scoped_id(self, repo: pathlib.Path) -> None:
478 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
479 result = runner.invoke(cli, _REQUIRED_ARGS + ["--json"])
480 data = json.loads(result.output.strip())
481 assert data["scoped_id"] == "@alice/genomics"
482
483 def test_json_has_manifest_hash(self, repo: pathlib.Path) -> None:
484 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
485 result = runner.invoke(cli, _REQUIRED_ARGS + ["--json"])
486 data = json.loads(result.output.strip())
487 assert data["manifest_hash"] == "sha256:abc123"
488
489 def test_json_has_domain_id(self, repo: pathlib.Path) -> None:
490 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
491 result = runner.invoke(cli, _REQUIRED_ARGS + ["--json"])
492 data = json.loads(result.output.strip())
493 assert data["domain_id"] == "dom-001"
494
495 def test_text_shows_scoped_id(self, repo: pathlib.Path) -> None:
496 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
497 result = runner.invoke(cli, _REQUIRED_ARGS)
498 assert "@alice/genomics" in result.output
499
500 def test_text_shows_manifest_hash(self, repo: pathlib.Path) -> None:
501 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
502 result = runner.invoke(cli, _REQUIRED_ARGS)
503 assert "sha256:abc123" in result.output
504
505 def test_text_shows_agent_quickstart(self, repo: pathlib.Path) -> None:
506 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
507 result = runner.invoke(cli, _REQUIRED_ARGS)
508 assert "musehub_get_domain" in result.output or "musehub_create_repo" in result.output
509
510 def test_missing_token_exits_1(self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
511 monkeypatch.setattr("muse.cli.commands.domains.get_signing_identity", lambda *a, **kw: None)
512 result = runner.invoke(cli, _REQUIRED_ARGS)
513 assert result.exit_code == 1
514
515 def test_version_flag_propagated(self, repo: pathlib.Path) -> None:
516 captured: list[bytes] = []
517
518 def _cap(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
519 captured.append(req.data or b"")
520 return _mock_ok()
521
522 with unittest.mock.patch("urllib.request.urlopen", side_effect=_cap):
523 result = runner.invoke(cli, _REQUIRED_ARGS + ["--version", "3.0.0"])
524 assert result.exit_code == 0
525 assert json.loads(captured[0])["version"] == "3.0.0"
526
527 def test_default_version_is_0_1_0(self, repo: pathlib.Path) -> None:
528 captured: list[bytes] = []
529
530 def _cap(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
531 captured.append(req.data or b"")
532 return _mock_ok()
533
534 args_no_version = [a for a in _REQUIRED_ARGS if a != "--version"]
535 with unittest.mock.patch("urllib.request.urlopen", side_effect=_cap):
536 result = runner.invoke(cli, args_no_version)
537 assert result.exit_code == 0
538 assert json.loads(captured[0])["version"] == "0.1.0"
539
540 def test_display_name_propagated_as_display_name(self, repo: pathlib.Path) -> None:
541 captured: list[bytes] = []
542
543 def _cap(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
544 captured.append(req.data or b"")
545 return _mock_ok()
546
547 with unittest.mock.patch("urllib.request.urlopen", side_effect=_cap):
548 result = runner.invoke(cli, _REQUIRED_ARGS)
549 assert result.exit_code == 0
550 assert json.loads(captured[0])["display_name"] == "Genomics"
551
552 def test_409_conflict_exits_1(self, repo: pathlib.Path) -> None:
553 import http.client, io
554 err = urllib.error.HTTPError(
555 url="https://hub.test/api/v1/domains", code=409,
556 msg="Conflict", hdrs=http.client.HTTPMessage(),
557 fp=io.BytesIO(b'{"error":"already_exists"}'),
558 )
559 with unittest.mock.patch("urllib.request.urlopen", side_effect=err):
560 result = runner.invoke(cli, _REQUIRED_ARGS)
561 assert result.exit_code == 1
562
563 def test_401_auth_failure_exits_1(self, repo: pathlib.Path) -> None:
564 import http.client, io
565 err = urllib.error.HTTPError(
566 url="https://hub.test/api/v1/domains", code=401,
567 msg="Unauthorized", hdrs=http.client.HTTPMessage(),
568 fp=io.BytesIO(b'{"error":"unauthorized"}'),
569 )
570 with unittest.mock.patch("urllib.request.urlopen", side_effect=err):
571 result = runner.invoke(cli, _REQUIRED_ARGS)
572 assert result.exit_code == 1
573
574 def test_bad_capabilities_json_exits_1(self, repo: pathlib.Path) -> None:
575 bad_caps_args = [
576 "domains", "publish",
577 "--author", "alice", "--slug", "x",
578 "--name", "X", "--description", "Y",
579 "--viewer-type", "v",
580 "--capabilities", "{bad json}",
581 "--hub", "https://hub.test",
582 ]
583 result = runner.invoke(cli, bad_caps_args)
584 assert result.exit_code == 1
585
586 def test_capabilities_dimensions_parsed_correctly(self, repo: pathlib.Path) -> None:
587 captured: list[bytes] = []
588
589 def _cap(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
590 captured.append(req.data or b"")
591 return _mock_ok()
592
593 with unittest.mock.patch("urllib.request.urlopen", side_effect=_cap):
594 result = runner.invoke(cli, _REQUIRED_ARGS)
595 assert result.exit_code == 0
596 dims = json.loads(captured[0])["capabilities"]["dimensions"]
597 assert any(d["name"] == "sequence" for d in dims)
598
599
600 # ---------------------------------------------------------------------------
601 # Security — muse domains publish
602 # ---------------------------------------------------------------------------
603
604
605 class TestPublishSecurity:
606 def test_file_scheme_rejected(self, repo: pathlib.Path) -> None:
607 """file:// hub URL is blocked before any network call (SSRF guard)."""
608 args = _REQUIRED_ARGS[:-2] + ["--hub", "file:///etc/passwd"]
609 result = runner.invoke(cli, args)
610 assert result.exit_code == 1
611 assert "file" in result.output.lower() or "scheme" in result.output.lower() or "Blocked" in result.output
612
613 def test_ftp_scheme_rejected(self, repo: pathlib.Path) -> None:
614 args = _REQUIRED_ARGS[:-2] + ["--hub", "ftp://evil.example.com"]
615 result = runner.invoke(cli, args)
616 assert result.exit_code == 1
617
618 def test_ansi_in_server_scoped_id_stripped_text(self, repo: pathlib.Path) -> None:
619 """ANSI in server-returned scoped_id is sanitized in text output."""
620 evil_body = json.dumps({
621 "domain_id": "dom-001",
622 "scoped_id": "@alice/\x1b[31mevil\x1b[0m",
623 "manifest_hash": "sha256:abc",
624 })
625 mock_resp = unittest.mock.MagicMock()
626 mock_resp.read.return_value = evil_body.encode()
627 mock_resp.__enter__ = unittest.mock.MagicMock(return_value=mock_resp)
628 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
629
630 with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp):
631 result = runner.invoke(cli, _REQUIRED_ARGS)
632 assert result.exit_code == 0
633 assert "\x1b[" not in result.output
634
635 def test_ansi_in_server_manifest_hash_stripped_text(self, repo: pathlib.Path) -> None:
636 """ANSI in server-returned manifest_hash is sanitized in text output."""
637 evil_body = json.dumps({
638 "domain_id": "dom-001",
639 "scoped_id": "@alice/genomics",
640 "manifest_hash": "sha256:\x1b[31mbad\x1b[0m",
641 })
642 mock_resp = unittest.mock.MagicMock()
643 mock_resp.read.return_value = evil_body.encode()
644 mock_resp.__enter__ = unittest.mock.MagicMock(return_value=mock_resp)
645 mock_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
646
647 with unittest.mock.patch("urllib.request.urlopen", return_value=mock_resp):
648 result = runner.invoke(cli, _REQUIRED_ARGS)
649 assert result.exit_code == 0
650 assert "\x1b[" not in result.output
651
652 def test_missing_token_no_network_call(self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
653 """When token is absent, no HTTP request is made."""
654 monkeypatch.setattr("muse.cli.commands.domains.get_signing_identity", lambda *a, **kw: None)
655 call_count: list[int] = [0]
656
657 def _fail(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
658 call_count[0] += 1
659 return _mock_ok()
660
661 with unittest.mock.patch("urllib.request.urlopen", side_effect=_fail):
662 result = runner.invoke(cli, _REQUIRED_ARGS)
663 assert result.exit_code == 1
664 assert call_count[0] == 0, "HTTP was called despite missing token"
665
666 def test_non_dict_server_response_exits_1(self, repo: pathlib.Path) -> None:
667 """If MuseHub returns a JSON array instead of object, exit cleanly."""
668 bad_resp = unittest.mock.MagicMock()
669 bad_resp.read.return_value = b"[1, 2, 3]"
670 bad_resp.__enter__ = unittest.mock.MagicMock(return_value=bad_resp)
671 bad_resp.__exit__ = unittest.mock.MagicMock(return_value=False)
672
673 with unittest.mock.patch("urllib.request.urlopen", return_value=bad_resp):
674 result = runner.invoke(cli, _REQUIRED_ARGS)
675 assert result.exit_code == 1
676
677
678 # ---------------------------------------------------------------------------
679 # Stress — muse domains publish (supplemental)
680 # ---------------------------------------------------------------------------
681
682
683 class TestPublishStress:
684 def test_50_sequential_invocations_all_succeed(self, repo: pathlib.Path) -> None:
685 """50 sequential CLI publish invocations all exit 0."""
686 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
687 for i in range(50):
688 result = runner.invoke(cli, _REQUIRED_ARGS)
689 assert result.exit_code == 0, f"Invocation {i} failed: {result.output}"
690
691 def test_large_description_10000_chars_transmitted(self, repo: pathlib.Path) -> None:
692 """10 000-character description is transmitted verbatim without truncation."""
693 long_desc = "X" * 10_000
694 captured: list[bytes] = []
695
696 def _cap(req: urllib.request.Request, timeout: float | None = None) -> unittest.mock.MagicMock:
697 captured.append(req.data or b"")
698 return _mock_ok()
699
700 args = [
701 "domains", "publish",
702 "--author", "alice", "--slug", "bigdesc",
703 "--name", "BigDesc",
704 "--description", long_desc,
705 "--viewer-type", "generic",
706 "--capabilities", '{"merge_semantics":"three_way"}',
707 "--hub", "https://hub.test",
708 ]
709 with unittest.mock.patch("urllib.request.urlopen", side_effect=_cap):
710 result = runner.invoke(cli, args)
711 assert result.exit_code == 0
712 assert json.loads(captured[0])["description"] == long_desc
713
714 def test_20_concurrent_post_json_calls(self) -> None:
715 """20 concurrent _post_json calls all return the expected scoped_id."""
716 import concurrent.futures
717 payload = _PublishPayload(
718 author_slug="alice", slug="genomics",
719 display_name="Genomics", description="DNA",
720 capabilities=_Capabilities(merge_semantics="three_way"),
721 viewer_type="genome", version="0.1.0",
722 )
723 with unittest.mock.patch("urllib.request.urlopen", return_value=_mock_ok()):
724 with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
725 futures = [
726 pool.submit(_post_json, "https://hub.test/api/v1/domains", payload, _make_signing())
727 for _ in range(20)
728 ]
729 results = [f.result() for f in futures]
730 assert len(results) == 20
731 assert all(r["scoped_id"] == "@alice/genomics" for r in results)
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago