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