gabriel / musehub public
test_mcp_mist_tools.py python
1,095 lines 42.7 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Section 16 — MCP Mist Tools: 7-layer test suite.
2
3 Covers the mist MCP executors:
4 Write tools (write_tools/mists.py):
5 execute_create_mist, execute_update_mist, execute_fork_mist, execute_delete_mist
6 Read tools (services/musehub_mcp_executor.py):
7 execute_read_mist, execute_list_mists, execute_read_mist_embed
8 Resource handlers (mcp/resources.py):
9 _read_mist, _read_owner_mists (via musehub://mists/... URIs)
10
11 Seven layers:
12
13 Layer 1 Unit:
14 - muse_mist_* tool names appear in correct catalogue sets
15 - _mist_data serialises MistResponse to correct dict keys
16 - execute_create_mist: empty actor → forbidden
17 - execute_create_mist: empty filename → missing_args
18 - execute_create_mist: empty content → missing_args
19 - execute_update_mist: empty actor → forbidden
20 - execute_fork_mist: empty actor → forbidden
21 - execute_delete_mist: empty actor → forbidden
22
23 Layer 2 Integration:
24 - execute_create_mist: happy path returns mist_id, artifact_type, content
25 - execute_create_mist: duplicate content → already_exists
26 - execute_update_mist: title change persisted
27 - execute_update_mist: non-owner → not_found
28 - execute_update_mist: visibility change to secret
29 - execute_delete_mist: happy path returns deleted=True
30 - execute_delete_mist: non-owner → not_found
31 - execute_delete_mist: unknown mist_id → not_found
32 - execute_fork_mist: happy path returns new mist_id and fork_parent_id
33 - execute_fork_mist: unknown source → not_found
34 - execute_read_mist: public mist readable by anon
35 - execute_read_mist: secret mist readable by owner
36 - execute_read_mist: secret mist blocked for non-owner
37 - execute_read_mist: unknown → not_found
38 - execute_list_mists: explore mode returns public mists
39 - execute_list_mists: owner mode returns owner's mists
40 - execute_list_mists: secret excluded for anon, included for owner
41 - execute_read_mist_embed: returns iframe, javascript, badge strings
42 - execute_read_mist_embed: secret mist → forbidden
43 - execute_read_mist_embed: unknown mist → not_found
44
45 Layer 3 E2E (HTTP tools/call):
46 - Anonymous muse_mist_create → 401
47 - Anonymous muse_mist_update → 401
48 - Anonymous muse_mist_fork → 401
49 - Anonymous muse_mist_delete → 401
50 - Authenticated muse_mist_create → isError=False, mist_id present
51 - Authenticated muse_mist_list (read tool) → isError=False, mists list
52
53 Layer 4 Stress:
54 - 10 sequential creates under 1000 ms
55
56 Layer 5 Data Integrity:
57 - Created mist retrievable via execute_read_mist
58 - Created mist appears in execute_list_mists(owner=...)
59 - Updated title persisted after execute_update_mist
60 - Deleted mist not found via execute_read_mist
61 - Fork parent_id correct + source fork_count incremented
62
63 Layer 6 Security:
64 - muse_mist_create/update/fork/delete in MUSEHUB_WRITE_TOOL_NAMES
65 - muse_mist_read/list/embed in read set (not in MUSEHUB_WRITE_TOOL_NAMES)
66 - Secret mist inaccessible via read executor to non-owner
67 - Secret mist excluded from explore listing
68 - Content returned as-is (no XSS transformation)
69
70 Layer 7 Performance:
71 - 10 sequential creates under 1000 ms
72 """
73 from __future__ import annotations
74
75 import json
76 import secrets
77 import time
78 from datetime import datetime, timezone, timedelta
79
80 import pytest
81 import pytest_asyncio
82 from httpx import AsyncClient, ASGITransport
83 from sqlalchemy.ext.asyncio import AsyncSession
84
85 from musehub.core.genesis import compute_identity_id, compute_repo_id
86 from musehub.db.musehub_repo_models import MusehubRepo
87 from musehub.main import app
88 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOL_NAMES, MUSEHUB_TOOL_NAMES
89 from musehub.types.json_types import JSONObject, StrDict
90 from musehub.mcp.write_tools.mists import (
91 _mist_data,
92 execute_create_mist,
93 execute_delete_mist,
94 execute_fork_mist,
95 execute_update_mist,
96 )
97 from musehub.services.musehub_mcp_executor import (
98 execute_list_mists,
99 execute_list_mist_forks,
100 execute_read_mist,
101 execute_read_mist_embed,
102 execute_read_mist_raw,
103 )
104 from musehub.mcp.resources import read_resource
105
106
107 # ── Fixtures ──────────────────────────────────────────────────────────────────
108
109
110 @pytest.fixture
111 def anyio_backend() -> str:
112 return "asyncio"
113
114
115 @pytest_asyncio.fixture
116 async def http_client(db_session: AsyncSession) -> AsyncClient:
117 async with AsyncClient(
118 transport=ASGITransport(app=app),
119 base_url="http://localhost",
120 ) as c:
121 yield c
122
123
124 # ── Helpers ───────────────────────────────────────────────────────────────────
125
126 _OWNER = "alice"
127 _PY_CONTENT = "def validate(x: str) -> bool:\n return bool(x)\n"
128 _PY_FILENAME = "validate.py"
129
130
131 def _uid() -> str:
132 return secrets.token_hex(16)
133
134
135 def _unique_content() -> str:
136 """Return content unique enough that its mist_id won't collide."""
137 return f"{_PY_CONTENT}# salt={secrets.token_hex(16)}"
138
139
140 def _tools_call(name: str, arguments: JSONObject) -> JSONObject:
141 return {
142 "jsonrpc": "2.0",
143 "id": 1,
144 "method": "tools/call",
145 "params": {"name": name, "arguments": arguments},
146 }
147
148
149 def _unwrap_tool_text(text: str) -> str:
150 text = text.strip()
151 if text.startswith("<musehub_tool_result>"):
152 text = text[len("<musehub_tool_result>"):].strip()
153 if text.endswith("</musehub_tool_result>"):
154 text = text[: -len("</musehub_tool_result>")].strip()
155 return text
156
157
158 async def _create(
159 content: str | None = None,
160 filename: str = _PY_FILENAME,
161 visibility: str = "public",
162 actor: str = _OWNER,
163 title: str = "",
164 ) -> "MusehubToolResult": # type: ignore[name-defined]
165 return await execute_create_mist(
166 filename=filename,
167 content=content or _unique_content(),
168 actor=actor,
169 title=title,
170 visibility=visibility,
171 )
172
173
174 # ── Layer 1 — Unit ────────────────────────────────────────────────────────────
175
176
177 class TestUnitToolCatalogue:
178 def test_mist_write_tools_in_write_set(self) -> None:
179 expected = {"muse_mist_create", "muse_mist_update", "muse_mist_fork", "muse_mist_delete"}
180 missing = expected - MUSEHUB_WRITE_TOOL_NAMES
181 assert not missing, f"Missing from write set: {missing}"
182
183 def test_mist_read_tools_NOT_in_write_set(self) -> None:
184 read_tools = {"muse_mist_read", "muse_mist_list", "muse_mist_embed"}
185 in_write = read_tools & MUSEHUB_WRITE_TOOL_NAMES
186 assert not in_write, f"Read tools incorrectly in write set: {in_write}"
187
188 def test_all_mist_tools_in_tool_names(self) -> None:
189 expected = {
190 "muse_mist_create", "muse_mist_update", "muse_mist_fork",
191 "muse_mist_delete", "muse_mist_read", "muse_mist_list", "muse_mist_embed",
192 }
193 missing = expected - MUSEHUB_TOOL_NAMES
194 assert not missing, f"Missing from MUSEHUB_TOOL_NAMES: {missing}"
195
196
197 class TestUnitMistDataHelper:
198 async def test_mist_data_keys(self, db_session: AsyncSession) -> None:
199 result = await _create()
200 assert result.ok is True
201 data = result.data
202 for key in ("mist_id", "owner", "artifact_type", "language", "filename",
203 "content", "size_bytes", "version", "visibility", "tags",
204 "symbol_anchors", "created_at", "updated_at"):
205 assert key in data, f"Missing key: {key}"
206
207
208 class TestUnitInputValidation:
209 async def test_create_empty_actor_returns_forbidden(self) -> None:
210 result = await execute_create_mist(filename="f.py", content="x", actor="")
211 assert result.ok is False
212 assert result.error_code == "forbidden"
213
214 async def test_create_empty_filename_returns_missing_args(self) -> None:
215 result = await execute_create_mist(filename="", content="x", actor=_OWNER)
216 assert result.ok is False
217 assert result.error_code == "missing_args"
218
219 async def test_create_empty_content_returns_missing_args(self) -> None:
220 result = await execute_create_mist(filename="f.py", content="", actor=_OWNER)
221 assert result.ok is False
222 assert result.error_code == "missing_args"
223
224 async def test_update_empty_actor_returns_forbidden(self) -> None:
225 result = await execute_update_mist(mist_id="aB3xKq9dPwNm", actor="")
226 assert result.ok is False
227 assert result.error_code == "forbidden"
228
229 async def test_fork_empty_actor_returns_forbidden(self) -> None:
230 result = await execute_fork_mist(mist_id="aB3xKq9dPwNm", actor="")
231 assert result.ok is False
232 assert result.error_code == "forbidden"
233
234 async def test_delete_empty_actor_returns_forbidden(self) -> None:
235 result = await execute_delete_mist(mist_id="aB3xKq9dPwNm", actor="")
236 assert result.ok is False
237 assert result.error_code == "forbidden"
238
239
240 # ── Layer 2 — Integration ─────────────────────────────────────────────────────
241
242
243 class TestIntegrationCreate:
244 async def test_create_happy_path(self, db_session: AsyncSession) -> None:
245 result = await _create()
246 assert result.ok is True
247 data = result.data
248 assert len(data["mist_id"]) == 12
249 assert data["artifact_type"] == "code"
250 assert data["language"] == "python"
251 assert data["owner"] == _OWNER
252 assert data["visibility"] == "public"
253 assert data["version"] == 1
254
255 async def test_create_duplicate_content_returns_already_exists(
256 self, db_session: AsyncSession
257 ) -> None:
258 content = _unique_content()
259 r1 = await execute_create_mist(filename=_PY_FILENAME, content=content, actor=_OWNER)
260 assert r1.ok is True
261 r2 = await execute_create_mist(filename=_PY_FILENAME, content=content, actor=_OWNER)
262 assert r2.ok is False
263 assert r2.error_code == "already_exists"
264
265 async def test_create_with_title_and_tags(self, db_session: AsyncSession) -> None:
266 result = await execute_create_mist(
267 filename=_PY_FILENAME,
268 content=_unique_content(),
269 actor=_OWNER,
270 title="My helper",
271 tags=["utils", "security"],
272 )
273 assert result.ok is True
274 assert result.data["title"] == "My helper"
275 assert result.data["tags"] == ["utils", "security"]
276
277 async def test_create_secret_mist(self, db_session: AsyncSession) -> None:
278 result = await execute_create_mist(
279 filename=_PY_FILENAME,
280 content=_unique_content(),
281 actor=_OWNER,
282 visibility="secret",
283 )
284 assert result.ok is True
285 assert result.data["visibility"] == "secret"
286
287
288 class TestIntegrationUpdate:
289 async def test_update_title(self, db_session: AsyncSession) -> None:
290 created = await _create()
291 mid = created.data["mist_id"]
292 result = await execute_update_mist(mist_id=mid, actor=_OWNER, title="New title")
293 assert result.ok is True
294 assert result.data["title"] == "New title"
295
296 async def test_update_visibility_to_secret(self, db_session: AsyncSession) -> None:
297 created = await _create()
298 mid = created.data["mist_id"]
299 result = await execute_update_mist(mist_id=mid, actor=_OWNER, visibility="secret")
300 assert result.ok is True
301 assert result.data["visibility"] == "secret"
302
303 async def test_update_content_increments_version(self, db_session: AsyncSession) -> None:
304 created = await _create()
305 mid = created.data["mist_id"]
306 result = await execute_update_mist(
307 mist_id=mid, actor=_OWNER, content="# new content\n"
308 )
309 assert result.ok is True
310 assert result.data["version"] == 2
311
312 async def test_update_non_owner_returns_not_found(self, db_session: AsyncSession) -> None:
313 created = await _create()
314 mid = created.data["mist_id"]
315 result = await execute_update_mist(mist_id=mid, actor="bob", title="Stolen")
316 assert result.ok is False
317 assert result.error_code == "not_found"
318
319 async def test_update_unknown_mist_returns_not_found(self, db_session: AsyncSession) -> None:
320 result = await execute_update_mist(mist_id="unknown12345", actor=_OWNER, title="X")
321 assert result.ok is False
322 assert result.error_code == "not_found"
323
324
325 class TestIntegrationDelete:
326 async def test_delete_happy_path(self, db_session: AsyncSession) -> None:
327 created = await _create()
328 mid = created.data["mist_id"]
329 result = await execute_delete_mist(mist_id=mid, actor=_OWNER)
330 assert result.ok is True
331 assert result.data["deleted"] is True
332 assert result.data["mist_id"] == mid
333
334 async def test_delete_non_owner_returns_not_found(self, db_session: AsyncSession) -> None:
335 created = await _create()
336 mid = created.data["mist_id"]
337 result = await execute_delete_mist(mist_id=mid, actor="bob")
338 assert result.ok is False
339 assert result.error_code == "not_found"
340
341 async def test_delete_unknown_returns_not_found(self, db_session: AsyncSession) -> None:
342 result = await execute_delete_mist(mist_id="unknown12345", actor=_OWNER)
343 assert result.ok is False
344 assert result.error_code == "not_found"
345
346
347 class TestIntegrationFork:
348 async def test_fork_happy_path(self, db_session: AsyncSession) -> None:
349 source = await _create(actor=_OWNER)
350 mid = source.data["mist_id"]
351 result = await execute_fork_mist(mist_id=mid, actor="bob")
352 assert result.ok is True
353 assert result.data["fork_parent_id"] == mid
354 assert result.data["owner"] == "bob"
355 assert result.data["mist_id"] != mid
356
357 async def test_fork_unknown_returns_not_found(self, db_session: AsyncSession) -> None:
358 result = await execute_fork_mist(mist_id="unknown12345", actor="bob")
359 assert result.ok is False
360 assert result.error_code == "not_found"
361
362
363 class TestIntegrationReadMist:
364 async def test_read_public_mist_anon(self, db_session: AsyncSession) -> None:
365 created = await _create()
366 mid = created.data["mist_id"]
367 result = await execute_read_mist(mid, actor="")
368 assert result.ok is True
369 assert result.data["mist_id"] == mid
370 assert "content" in result.data
371
372 async def test_read_secret_mist_as_owner(self, db_session: AsyncSession) -> None:
373 created = await _create(visibility="secret")
374 mid = created.data["mist_id"]
375 result = await execute_read_mist(mid, actor=_OWNER)
376 assert result.ok is True
377
378 async def test_read_secret_mist_as_non_owner_returns_forbidden(
379 self, db_session: AsyncSession
380 ) -> None:
381 created = await _create(visibility="secret")
382 mid = created.data["mist_id"]
383 result = await execute_read_mist(mid, actor="bob")
384 assert result.ok is False
385 assert result.error_code == "forbidden"
386
387 async def test_read_unknown_returns_not_found(self, db_session: AsyncSession) -> None:
388 result = await execute_read_mist("unknown12345")
389 assert result.ok is False
390 assert result.error_code == "not_found"
391
392
393 class TestIntegrationListMists:
394 async def test_explore_returns_public(self, db_session: AsyncSession) -> None:
395 created = await _create(actor=_OWNER)
396 mid = created.data["mist_id"]
397 result = await execute_list_mists(owner=None)
398 assert result.ok is True
399 ids = {m["mist_id"] for m in result.data["mists"]}
400 assert mid in ids
401
402 async def test_explore_excludes_secret(self, db_session: AsyncSession) -> None:
403 created = await _create(visibility="secret")
404 mid = created.data["mist_id"]
405 result = await execute_list_mists(owner=None)
406 assert result.ok is True
407 ids = {m["mist_id"] for m in result.data["mists"]}
408 assert mid not in ids
409
410 async def test_owner_mode_includes_public(self, db_session: AsyncSession) -> None:
411 created = await _create(actor=_OWNER)
412 mid = created.data["mist_id"]
413 result = await execute_list_mists(owner=_OWNER)
414 assert result.ok is True
415 ids = {m["mist_id"] for m in result.data["mists"]}
416 assert mid in ids
417
418 async def test_owner_mode_excludes_secret_for_anon(self, db_session: AsyncSession) -> None:
419 created = await _create(visibility="secret")
420 mid = created.data["mist_id"]
421 result = await execute_list_mists(owner=_OWNER, include_secret=True, actor="bob")
422 assert result.ok is True
423 ids = {m["mist_id"] for m in result.data["mists"]}
424 assert mid not in ids
425
426 async def test_owner_mode_includes_secret_for_owner(self, db_session: AsyncSession) -> None:
427 created = await _create(visibility="secret", actor=_OWNER)
428 mid = created.data["mist_id"]
429 result = await execute_list_mists(owner=_OWNER, include_secret=True, actor=_OWNER)
430 assert result.ok is True
431 ids = {m["mist_id"] for m in result.data["mists"]}
432 assert mid in ids
433
434
435 class TestIntegrationEmbed:
436 async def test_embed_public_mist(self, db_session: AsyncSession) -> None:
437 created = await _create()
438 mid = created.data["mist_id"]
439 result = await execute_read_mist_embed(mid, owner=_OWNER)
440 assert result.ok is True
441 data = result.data
442 assert "iframe" in data
443 assert "javascript" in data
444 assert "badge" in data
445 assert mid in data["iframe"]
446
447 async def test_embed_secret_mist_returns_forbidden(self, db_session: AsyncSession) -> None:
448 created = await _create(visibility="secret")
449 mid = created.data["mist_id"]
450 result = await execute_read_mist_embed(mid, owner=_OWNER)
451 assert result.ok is False
452 assert result.error_code == "forbidden"
453
454 async def test_embed_unknown_mist_returns_not_found(self, db_session: AsyncSession) -> None:
455 result = await execute_read_mist_embed("unknown12345", owner="nobody")
456 assert result.ok is False
457 assert result.error_code == "not_found"
458
459
460 class TestIntegrationResource:
461 async def test_read_resource_single_mist(self, db_session: AsyncSession) -> None:
462 created = await _create()
463 mid = created.data["mist_id"]
464 data = await read_resource(f"musehub://mists/{_OWNER}/{mid}")
465 assert "error" not in data
466 assert data["mist_id"] == mid
467 assert "content" in data
468
469 async def test_read_resource_owner_mists(self, db_session: AsyncSession) -> None:
470 created = await _create(actor=_OWNER)
471 mid = created.data["mist_id"]
472 data = await read_resource(f"musehub://mists/{_OWNER}")
473 assert "error" not in data
474 ids = {m["mist_id"] for m in data["mists"]}
475 assert mid in ids
476
477 async def test_read_resource_unknown_mist(self, db_session: AsyncSession) -> None:
478 data = await read_resource("musehub://mists/nobody/unknown12345")
479 assert "error" in data
480
481 async def test_read_resource_secret_mist_blocked_for_anon(
482 self, db_session: AsyncSession
483 ) -> None:
484 created = await _create(visibility="secret")
485 mid = created.data["mist_id"]
486 data = await read_resource(f"musehub://mists/{_OWNER}/{mid}", user_id=None)
487 assert "error" in data
488
489
490 # ── Layer 3 — End-to-End ──────────────────────────────────────────────────────
491
492
493 class TestE2EAuthGate:
494 """Write tool calls without auth must return 401."""
495
496 async def test_create_mist_no_auth(self, http_client: AsyncClient) -> None:
497 resp = await http_client.post(
498 "/mcp",
499 json=_tools_call("muse_mist_create", {"filename": "f.py", "content": "x"}),
500 headers={"Content-Type": "application/json"},
501 )
502 assert resp.status_code == 401
503
504 async def test_update_mist_no_auth(self, http_client: AsyncClient) -> None:
505 resp = await http_client.post(
506 "/mcp",
507 json=_tools_call("muse_mist_update", {"mist_id": "aB3xKq9dPwNm"}),
508 headers={"Content-Type": "application/json"},
509 )
510 assert resp.status_code == 401
511
512 async def test_fork_mist_no_auth(self, http_client: AsyncClient) -> None:
513 resp = await http_client.post(
514 "/mcp",
515 json=_tools_call("muse_mist_fork", {"mist_id": "aB3xKq9dPwNm"}),
516 headers={"Content-Type": "application/json"},
517 )
518 assert resp.status_code == 401
519
520 async def test_delete_mist_no_auth(self, http_client: AsyncClient) -> None:
521 resp = await http_client.post(
522 "/mcp",
523 json=_tools_call("muse_mist_delete", {"mist_id": "aB3xKq9dPwNm"}),
524 headers={"Content-Type": "application/json"},
525 )
526 assert resp.status_code == 401
527
528 async def test_create_mist_with_auth(
529 self, http_client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
530 ) -> None:
531 content = _unique_content()
532 resp = await http_client.post(
533 "/mcp",
534 json=_tools_call("muse_mist_create", {
535 "filename": _PY_FILENAME,
536 "content": content,
537 "title": "E2E mist",
538 }),
539 headers=auth_headers,
540 )
541 assert resp.status_code == 200
542 result = resp.json()["result"]
543 assert result["isError"] is False
544 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
545 assert "mist_id" in payload
546 assert payload["title"] == "E2E mist"
547
548 async def test_list_mists_read_tool_no_auth(self, http_client: AsyncClient) -> None:
549 """muse_mist_list is a read tool — accessible without auth."""
550 resp = await http_client.post(
551 "/mcp",
552 json=_tools_call("muse_mist_list", {}),
553 headers={"Content-Type": "application/json"},
554 )
555 # Read tools don't require auth at the HTTP layer
556 assert resp.status_code in (200, 401)
557 if resp.status_code == 200:
558 result = resp.json()["result"]
559 assert result["isError"] is False
560
561
562 # ── Layer 4 — Stress ──────────────────────────────────────────────────────────
563
564
565 class TestStressMistTools:
566 async def test_10_sequential_creates(self, db_session: AsyncSession) -> None:
567 start = time.monotonic()
568 ids: list[str] = []
569 for _ in range(10):
570 result = await execute_create_mist(
571 filename=_PY_FILENAME,
572 content=_unique_content(),
573 actor=_OWNER,
574 )
575 assert result.ok is True
576 ids.append(result.data["mist_id"])
577 elapsed = time.monotonic() - start
578 assert elapsed < 1.0, f"10 creates took {elapsed:.2f}s (> 1s)"
579 assert len(set(ids)) == 10, "All mist IDs must be unique"
580
581
582 # ── Layer 5 — Data Integrity ──────────────────────────────────────────────────
583
584
585 class TestDataIntegrity:
586 async def test_created_mist_retrievable(self, db_session: AsyncSession) -> None:
587 created = await _create(title="Persistent")
588 mid = created.data["mist_id"]
589 read = await execute_read_mist(mid, actor=_OWNER)
590 assert read.ok is True
591 assert read.data["mist_id"] == mid
592 assert read.data["title"] == "Persistent"
593
594 async def test_created_mist_in_owner_list(self, db_session: AsyncSession) -> None:
595 created = await _create(actor=_OWNER)
596 mid = created.data["mist_id"]
597 result = await execute_list_mists(owner=_OWNER)
598 assert result.ok is True
599 ids = {m["mist_id"] for m in result.data["mists"]}
600 assert mid in ids
601
602 async def test_update_title_persisted(self, db_session: AsyncSession) -> None:
603 created = await _create()
604 mid = created.data["mist_id"]
605 await execute_update_mist(mist_id=mid, actor=_OWNER, title="Persisted title")
606 read = await execute_read_mist(mid)
607 assert read.ok is True
608 assert read.data["title"] == "Persisted title"
609
610 async def test_deleted_mist_not_found(self, db_session: AsyncSession) -> None:
611 created = await _create()
612 mid = created.data["mist_id"]
613 del_result = await execute_delete_mist(mist_id=mid, actor=_OWNER)
614 assert del_result.ok is True
615 read = await execute_read_mist(mid)
616 assert read.ok is False
617 assert read.error_code == "not_found"
618
619 async def test_fork_parent_id_and_source_fork_count(self, db_session: AsyncSession) -> None:
620 source = await _create(actor=_OWNER)
621 mid = source.data["mist_id"]
622 fork = await execute_fork_mist(mist_id=mid, actor="bob")
623 assert fork.ok is True
624 assert fork.data["fork_parent_id"] == mid
625 # Source fork_count incremented — verify via read
626 read = await execute_read_mist(mid, actor=_OWNER)
627 assert read.ok is True
628 assert read.data["fork_count"] >= 1
629
630 async def test_view_count_increments_on_read(self, db_session: AsyncSession) -> None:
631 created = await _create()
632 mid = created.data["mist_id"]
633 r1 = await execute_read_mist(mid)
634 r2 = await execute_read_mist(mid)
635 assert r2.data["view_count"] > r1.data["view_count"]
636
637 async def test_embed_count_increments_on_embed(self, db_session: AsyncSession) -> None:
638 created = await _create()
639 mid = created.data["mist_id"]
640 r1 = await execute_read_mist(mid)
641 await execute_read_mist_embed(mid, owner=_OWNER)
642 r2 = await execute_read_mist(mid)
643 assert r2.data["embed_count"] > r1.data["embed_count"]
644
645
646 # ── Layer 6 — Security ────────────────────────────────────────────────────────
647
648
649 class TestSecurity:
650 def test_write_tools_in_auth_gate_set(self) -> None:
651 write_tools = {"muse_mist_create", "muse_mist_update", "muse_mist_fork", "muse_mist_delete"}
652 missing = write_tools - MUSEHUB_WRITE_TOOL_NAMES
653 assert not missing, f"Write tools missing from auth gate: {missing}"
654
655 def test_read_tools_not_in_write_set(self) -> None:
656 read_tools = {"muse_mist_read", "muse_mist_list", "muse_mist_embed"}
657 in_write = read_tools & MUSEHUB_WRITE_TOOL_NAMES
658 assert not in_write, f"Read tools in write auth gate (shouldn't be): {in_write}"
659
660 async def test_secret_mist_not_in_explore(self, db_session: AsyncSession) -> None:
661 created = await _create(visibility="secret")
662 mid = created.data["mist_id"]
663 result = await execute_list_mists(owner=None, actor="")
664 ids = {m["mist_id"] for m in result.data["mists"]}
665 assert mid not in ids, "Secret mist must not appear in explore feed"
666
667 async def test_secret_mist_blocked_for_non_owner_read(self, db_session: AsyncSession) -> None:
668 created = await _create(visibility="secret")
669 mid = created.data["mist_id"]
670 result = await execute_read_mist(mid, actor="bob")
671 assert result.ok is False
672 assert result.error_code == "forbidden"
673
674 async def test_content_returned_verbatim_no_xss_transform(
675 self, db_session: AsyncSession
676 ) -> None:
677 """Content is returned verbatim — XSS prevention is a renderer concern."""
678 xss_payload = '<script>alert("xss")</script>'
679 created = await execute_create_mist(
680 filename="test.html",
681 content=xss_payload,
682 actor=_OWNER,
683 )
684 assert created.ok is True
685 mid = created.data["mist_id"]
686 read = await execute_read_mist(mid, actor=_OWNER)
687 assert read.ok is True
688 assert read.data["content"] == xss_payload
689
690 async def test_agent_id_stored_verbatim(self, db_session: AsyncSession) -> None:
691 """agent_id is stored as opaque string — no injection risk in storage."""
692 agent = "agentception-worker-42; DROP TABLE mists;--"
693 created = await execute_create_mist(
694 filename=_PY_FILENAME,
695 content=_unique_content(),
696 actor=_OWNER,
697 agent_id=agent,
698 )
699 assert created.ok is True
700 assert created.data["agent_id"] == agent
701
702
703 # ── Layer 7 — Performance ─────────────────────────────────────────────────────
704
705
706 class TestPerformance:
707 async def test_10_creates_under_500ms(self, db_session: AsyncSession) -> None:
708 start = time.monotonic()
709 for _ in range(10):
710 result = await execute_create_mist(
711 filename=_PY_FILENAME,
712 content=_unique_content(),
713 actor=_OWNER,
714 )
715 assert result.ok is True
716 elapsed = time.monotonic() - start
717 assert elapsed < 0.5, f"10 creates took {elapsed:.2f}s (> 500ms)"
718
719 async def test_list_100_mists_under_200ms(self, db_session: AsyncSession) -> None: # noqa: E501
720 from muse.plugins.mist.plugin import compute_mist_id
721
722 base_time = datetime.now(tz=timezone.utc)
723 unique_type = f"perf_{secrets.token_hex(4)}"
724 owner_id = compute_identity_id(_OWNER.encode())
725 for i in range(20):
726 content = f"perf_{i}_{secrets.token_hex(16)}"
727 mid = compute_mist_id(content.encode())
728 from musehub.db.musehub_repo_models import MusehubMist
729 slug = secrets.token_hex(6)
730 created_at = base_time
731 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
732 repo = MusehubRepo(
733 repo_id=repo_id,
734 name=slug,
735 owner=_OWNER,
736 slug=slug,
737 visibility="public",
738 owner_user_id=owner_id,
739 created_at=created_at,
740 updated_at=created_at,
741 )
742 db_session.add(repo)
743 await db_session.flush()
744 await db_session.refresh(repo)
745 row = MusehubMist(
746 mist_id=mid,
747 repo_id=str(repo.repo_id),
748 owner=_OWNER,
749 filename="p.py",
750 content=content,
751 artifact_type=unique_type,
752 language="python",
753 visibility="public",
754 tags=[],
755 symbol_anchors=[],
756 created_at=base_time + timedelta(seconds=i),
757 updated_at=base_time + timedelta(seconds=i),
758 )
759 db_session.add(row)
760 await db_session.commit()
761
762 start = time.monotonic()
763 result = await execute_list_mists(
764 artifact_type=unique_type,
765 limit=20,
766 )
767 elapsed = time.monotonic() - start
768 assert result.ok is True
769 assert elapsed < 0.2, f"list 20 mists took {elapsed:.2f}s (> 200ms)"
770
771
772 # ── execute_list_mist_forks tests ─────────────────────────────────────────────
773
774
775 @pytest.mark.anyio
776 class TestListMistForks:
777 """Tests for execute_list_mist_forks — all 8 tiers.
778
779 Covers: empty mist_id guard, not_found, forbidden (secret parent,
780 non-owner actor), happy path with zero forks, happy path with forks,
781 limit clamping, and performance (<200ms for 10 forks).
782 """
783
784 async def test_empty_mist_id_returns_missing_args(
785 self, db_session: AsyncSession
786 ) -> None:
787 """Empty mist_id returns missing_args immediately without a DB hit."""
788 result = await execute_list_mist_forks("")
789 assert result.ok is False
790 assert result.error_code == "missing_args"
791
792 async def test_unknown_mist_returns_not_found(
793 self, db_session: AsyncSession
794 ) -> None:
795 """Non-existent parent mist returns not_found."""
796 result = await execute_list_mist_forks("NoSuchMistXX")
797 assert result.ok is False
798 assert result.error_code == "not_found"
799
800 async def test_secret_parent_anon_returns_forbidden(
801 self, db_session: AsyncSession
802 ) -> None:
803 """Secret parent mist with anonymous actor returns forbidden."""
804 created = await execute_create_mist(
805 filename=_PY_FILENAME,
806 content=_unique_content(),
807 actor=_OWNER,
808 visibility="secret",
809 )
810 assert created.ok is True
811 mist_id = created.data["mist_id"]
812
813 result = await execute_list_mist_forks(mist_id, actor="")
814 assert result.ok is False
815 assert result.error_code == "forbidden"
816
817 async def test_public_parent_no_forks_returns_empty_list(
818 self, db_session: AsyncSession
819 ) -> None:
820 """Public parent with no forks returns empty forks list, total=0."""
821 created = await execute_create_mist(
822 filename=_PY_FILENAME,
823 content=_unique_content(),
824 actor=_OWNER,
825 )
826 assert created.ok is True
827 mist_id = created.data["mist_id"]
828
829 result = await execute_list_mist_forks(mist_id)
830 assert result.ok is True
831 assert result.data["mist_id"] == mist_id
832 assert result.data["total"] == 0
833 assert result.data["forks"] == []
834
835 async def test_forks_appear_after_fork_creation(
836 self, db_session: AsyncSession
837 ) -> None:
838 """After forking a mist, execute_list_mist_forks returns the fork."""
839 parent = await execute_create_mist(
840 filename=_PY_FILENAME,
841 content=_unique_content(),
842 actor=_OWNER,
843 )
844 assert parent.ok is True
845 parent_id = parent.data["mist_id"]
846
847 fork = await execute_fork_mist(mist_id=parent_id, actor="otheruser")
848 assert fork.ok is True
849
850 result = await execute_list_mist_forks(parent_id)
851 assert result.ok is True
852 assert result.data["total"] == 1
853 fork_entry = result.data["forks"][0]
854 assert fork_entry["owner"] == "otheruser"
855 assert fork_entry["mist_id"] == fork.data["mist_id"]
856
857 async def test_limit_clamped_to_100(
858 self, db_session: AsyncSession
859 ) -> None:
860 """Passing limit=200 is silently clamped to 100 (no error)."""
861 created = await execute_create_mist(
862 filename=_PY_FILENAME,
863 content=_unique_content(),
864 actor=_OWNER,
865 )
866 assert created.ok is True
867
868 result = await execute_list_mist_forks(
869 created.data["mist_id"], limit=200
870 )
871 assert result.ok is True
872
873 async def test_secret_parent_owner_can_list_forks(
874 self, db_session: AsyncSession
875 ) -> None:
876 """Owner of a secret parent can list its forks."""
877 created = await execute_create_mist(
878 filename=_PY_FILENAME,
879 content=_unique_content(),
880 actor=_OWNER,
881 visibility="secret",
882 )
883 assert created.ok is True
884
885 result = await execute_list_mist_forks(
886 created.data["mist_id"], actor=_OWNER
887 )
888 assert result.ok is True
889 assert result.data["total"] == 0
890
891 async def test_fork_entry_has_required_keys(
892 self, db_session: AsyncSession
893 ) -> None:
894 """Each fork entry contains the required schema keys."""
895 parent = await execute_create_mist(
896 filename=_PY_FILENAME,
897 content=_unique_content(),
898 actor=_OWNER,
899 )
900 assert parent.ok is True
901 await execute_fork_mist(mist_id=parent.data["mist_id"], actor="otheruser")
902
903 result = await execute_list_mist_forks(parent.data["mist_id"])
904 assert result.ok is True
905 entry = result.data["forks"][0]
906 for key in ("mist_id", "owner", "filename", "artifact_type",
907 "fork_depth", "fork_count", "visibility", "tags",
908 "created_at"):
909 assert key in entry, f"Missing key '{key}' in fork entry"
910
911 async def test_muse_mist_list_forks_in_tool_catalogue(self) -> None:
912 """muse_mist_list_forks appears in MUSEHUB_TOOL_NAMES."""
913 assert "muse_mist_list_forks" in MUSEHUB_TOOL_NAMES
914
915 async def test_muse_mist_list_forks_not_in_write_tools(self) -> None:
916 """muse_mist_list_forks is a read tool — must not appear in write set."""
917 assert "muse_mist_list_forks" not in MUSEHUB_WRITE_TOOL_NAMES
918
919 async def test_10_forks_listed_under_200ms(
920 self, db_session: AsyncSession
921 ) -> None:
922 """Listing 10 forks completes in under 200ms."""
923 parent = await execute_create_mist(
924 filename=_PY_FILENAME,
925 content=_unique_content(),
926 actor=_OWNER,
927 )
928 assert parent.ok is True
929 parent_id = parent.data["mist_id"]
930
931 for i in range(10):
932 fork = await execute_fork_mist(mist_id=parent_id, actor=f"user{i}")
933 assert fork.ok is True
934
935 start = time.monotonic()
936 result = await execute_list_mist_forks(parent_id, limit=10)
937 elapsed = time.monotonic() - start
938 assert result.ok is True
939 assert result.data["total"] == 10
940 assert elapsed < 0.2, f"listing 10 forks took {elapsed:.2f}s (> 200ms)"
941
942
943 # ── execute_read_mist_raw tests ───────────────────────────────────────────────
944
945
946 @pytest.mark.anyio
947 class TestReadMistRaw:
948 """Tests for execute_read_mist_raw — all 8 tiers.
949
950 Covers: empty mist_id guard, not_found, forbidden (secret mist,
951 non-owner), happy path content/keys, view counter increment,
952 performance (<50ms), and tool catalogue membership.
953 """
954
955 async def test_empty_mist_id_returns_missing_args(
956 self, db_session: AsyncSession
957 ) -> None:
958 """Empty mist_id returns missing_args without a DB hit."""
959 result = await execute_read_mist_raw("")
960 assert result.ok is False
961 assert result.error_code == "missing_args"
962
963 async def test_unknown_mist_returns_not_found(
964 self, db_session: AsyncSession
965 ) -> None:
966 """Non-existent mist_id returns not_found."""
967 result = await execute_read_mist_raw("NoSuchMistXX")
968 assert result.ok is False
969 assert result.error_code == "not_found"
970
971 async def test_secret_mist_anon_returns_forbidden(
972 self, db_session: AsyncSession
973 ) -> None:
974 """Anonymous actor cannot read a secret mist."""
975 created = await execute_create_mist(
976 filename=_PY_FILENAME,
977 content=_unique_content(),
978 actor=_OWNER,
979 visibility="secret",
980 )
981 assert created.ok is True
982
983 result = await execute_read_mist_raw(created.data["mist_id"], actor="")
984 assert result.ok is False
985 assert result.error_code == "forbidden"
986
987 async def test_secret_mist_non_owner_returns_forbidden(
988 self, db_session: AsyncSession
989 ) -> None:
990 """Non-owner actor cannot read a secret mist."""
991 created = await execute_create_mist(
992 filename=_PY_FILENAME,
993 content=_unique_content(),
994 actor=_OWNER,
995 visibility="secret",
996 )
997 assert created.ok is True
998
999 result = await execute_read_mist_raw(
1000 created.data["mist_id"], actor="intruder"
1001 )
1002 assert result.ok is False
1003 assert result.error_code == "forbidden"
1004
1005 async def test_secret_mist_owner_can_read_raw(
1006 self, db_session: AsyncSession
1007 ) -> None:
1008 """Owner can read a secret mist's raw content."""
1009 content = _unique_content()
1010 created = await execute_create_mist(
1011 filename=_PY_FILENAME,
1012 content=content,
1013 actor=_OWNER,
1014 visibility="secret",
1015 )
1016 assert created.ok is True
1017
1018 result = await execute_read_mist_raw(
1019 created.data["mist_id"], actor=_OWNER
1020 )
1021 assert result.ok is True
1022 assert result.data["content"] == content
1023
1024 async def test_public_mist_readable_by_anon(
1025 self, db_session: AsyncSession
1026 ) -> None:
1027 """Public mist is readable by anonymous caller."""
1028 content = _unique_content()
1029 created = await execute_create_mist(
1030 filename=_PY_FILENAME,
1031 content=content,
1032 actor=_OWNER,
1033 )
1034 assert created.ok is True
1035
1036 result = await execute_read_mist_raw(created.data["mist_id"])
1037 assert result.ok is True
1038 assert result.data["content"] == content
1039
1040 async def test_data_has_required_keys(
1041 self, db_session: AsyncSession
1042 ) -> None:
1043 """Successful result contains all expected data keys."""
1044 created = await execute_create_mist(
1045 filename=_PY_FILENAME,
1046 content=_unique_content(),
1047 actor=_OWNER,
1048 )
1049 assert created.ok is True
1050
1051 result = await execute_read_mist_raw(created.data["mist_id"])
1052 assert result.ok is True
1053 for key in ("mist_id", "filename", "artifact_type",
1054 "language", "size_bytes", "content"):
1055 assert key in result.data, f"Missing key '{key}' in raw result"
1056
1057 async def test_size_bytes_matches_content_length(
1058 self, db_session: AsyncSession
1059 ) -> None:
1060 """size_bytes in the result equals the UTF-8 byte length of content."""
1061 content = _unique_content()
1062 created = await execute_create_mist(
1063 filename=_PY_FILENAME,
1064 content=content,
1065 actor=_OWNER,
1066 )
1067 assert created.ok is True
1068
1069 result = await execute_read_mist_raw(created.data["mist_id"])
1070 assert result.ok is True
1071 assert result.data["size_bytes"] == len(content.encode("utf-8"))
1072
1073 async def test_muse_mist_raw_in_tool_catalogue(self) -> None:
1074 """muse_mist_raw appears in MUSEHUB_TOOL_NAMES."""
1075 assert "muse_mist_raw" in MUSEHUB_TOOL_NAMES
1076
1077 async def test_muse_mist_raw_not_in_write_tools(self) -> None:
1078 """muse_mist_raw is a read tool — must not appear in the write set."""
1079 assert "muse_mist_raw" not in MUSEHUB_WRITE_TOOL_NAMES
1080
1081 async def test_raw_under_50ms(self, db_session: AsyncSession) -> None:
1082 """Raw read of a 1 KiB mist completes in under 50ms."""
1083 content = f"x = 1\n# {'a' * 500}\n"
1084 created = await execute_create_mist(
1085 filename=_PY_FILENAME,
1086 content=content,
1087 actor=_OWNER,
1088 )
1089 assert created.ok is True
1090
1091 start = time.monotonic()
1092 result = await execute_read_mist_raw(created.data["mist_id"])
1093 elapsed = time.monotonic() - start
1094 assert result.ok is True
1095 assert elapsed < 0.05, f"raw read took {elapsed:.3f}s (> 50ms)"
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago