gabriel / musehub public

test_protocol_introspection.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:0 fix: fall back to any indexed mpack in read_object_bytes when push mpac… · gabriel · Jun 17, 2026
1 """Section 42 β€” Protocol Introspection: 7-layer test suite.
2
3 Covers:
4 - musehub/protocol/events.py::EVENT_REGISTRY
5 - musehub/protocol/responses.py::compute_protocol_hash, ProtocolInfoResponse,
6 build_protocol_info
7 - musehub/api/routes/protocol.py::get_protocol_info, get_events_json,
8 get_tools_json, get_schema_json
9 """
10 from __future__ import annotations
11
12 import json
13 import time
14
15 import pytest
16 from muse.core.types import content_hash
17
18 from musehub.mcp.tools.musehub import MUSEHUB_TOOLS, MUSEHUB_TOOL_NAMES
19 from musehub.protocol.events import EVENT_REGISTRY
20 from musehub.protocol.responses import (
21 ProtocolInfoResponse,
22 build_protocol_info,
23 compute_protocol_hash,
24 )
25 from musehub.protocol.version import MUSE_VERSION
26
27
28 # ─────────────────────────────────────────────────────────────────────────────
29 # LAYER 1 β€” UNIT
30 # ─────────────────────────────────────────────────────────────────────────────
31
32
33 class TestEventRegistryUnit:
34 """Unit: EVENT_REGISTRY structure and completeness."""
35
36 def test_event_registry_is_frozenset(self) -> None:
37 assert isinstance(EVENT_REGISTRY, frozenset)
38
39 def test_event_registry_non_empty(self) -> None:
40 assert len(EVENT_REGISTRY) > 0
41
42 def test_event_registry_contains_core_events(self) -> None:
43 for event in (
44 "commit_pushed",
45 "proposal_opened",
46 "proposal_merged",
47 "proposal_closed",
48 "issue_opened",
49 "issue_closed",
50 "branch_created",
51 "branch_deleted",
52 ):
53 assert event in EVENT_REGISTRY, f"{event!r} missing from EVENT_REGISTRY"
54
55 def test_event_registry_all_strings(self) -> None:
56 assert all(isinstance(e, str) for e in EVENT_REGISTRY)
57
58 def test_event_registry_no_empty_strings(self) -> None:
59 assert all(e.strip() for e in EVENT_REGISTRY)
60
61 def test_event_registry_all_snake_case(self) -> None:
62 for event in EVENT_REGISTRY:
63 assert event == event.lower(), f"{event!r} is not lowercase"
64 assert " " not in event, f"{event!r} contains spaces"
65
66 def test_event_registry_immutable(self) -> None:
67 """EVENT_REGISTRY is a frozenset β€” it has no add() method."""
68 assert not hasattr(EVENT_REGISTRY, "add"), (
69 "EVENT_REGISTRY must be a frozenset β€” it must not have an add() method"
70 )
71 assert isinstance(EVENT_REGISTRY, frozenset), (
72 "EVENT_REGISTRY must be a frozenset to ensure immutability"
73 )
74
75
76 class TestComputeProtocolHashUnit:
77 """Unit: compute_protocol_hash determinism and correctness."""
78
79 def test_returns_canonical_id_string(self) -> None:
80 h = compute_protocol_hash({"key": "value"})
81 assert isinstance(h, str)
82 assert h.startswith("sha256:")
83 assert len(h) == 71 # sha256:<64-hex>
84 hex_part = h[7:]
85 assert all(c in "0123456789abcdef" for c in hex_part)
86
87 def test_same_input_same_hash(self) -> None:
88 data = {"events": ["a", "b"], "tools": [{"name": "t1"}]}
89 assert compute_protocol_hash(data) == compute_protocol_hash(data)
90
91 def test_key_order_does_not_affect_hash(self) -> None:
92 a = {"b": 2, "a": 1}
93 b = {"a": 1, "b": 2}
94 assert compute_protocol_hash(a) == compute_protocol_hash(b)
95
96 def test_different_data_different_hash(self) -> None:
97 assert compute_protocol_hash({"x": 1}) != compute_protocol_hash({"x": 2})
98
99 def test_hash_is_sha256_of_canonical_json(self) -> None:
100 data = {"events": ["push"], "tools": []}
101 assert compute_protocol_hash(data) == content_hash(data)
102
103 def test_list_input_hashes_correctly(self) -> None:
104 h = compute_protocol_hash(["a", "b", "c"])
105 assert isinstance(h, str) and len(h) == 71 # sha256:<64-hex>
106
107 def test_empty_dict_hashes_deterministically(self) -> None:
108 assert compute_protocol_hash({}) == compute_protocol_hash({})
109
110 def test_nested_structure_hashes_consistently(self) -> None:
111 data = {"outer": {"inner": [1, 2, 3]}}
112 assert compute_protocol_hash(data) == compute_protocol_hash(data)
113
114
115 class TestProtocolInfoResponseUnit:
116 """Unit: ProtocolInfoResponse Pydantic model."""
117
118 def test_required_fields_present(self) -> None:
119 r = ProtocolInfoResponse(
120 version="1.2.3",
121 protocol_hash="a" * 64,
122 event_count=12,
123 tool_count=40,
124 )
125 assert r.version == "1.2.3"
126 assert r.protocol_hash == "a" * 64
127 assert r.event_count == 12
128 assert r.tool_count == 40
129
130 def test_serialises_to_dict(self) -> None:
131 r = ProtocolInfoResponse(
132 version="0.1.0",
133 protocol_hash="b" * 64,
134 event_count=5,
135 tool_count=10,
136 )
137 d = r.model_dump()
138 assert set(d.keys()) == {"version", "protocol_hash", "event_count", "tool_count"}
139
140 def test_build_protocol_info_uses_muse_version(self) -> None:
141 info = build_protocol_info(event_count=5, tool_count=10, schema={"x": 1})
142 assert info.version == MUSE_VERSION
143
144 def test_build_protocol_info_hash_from_schema(self) -> None:
145 schema = {"events": ["a"], "tools": []}
146 info = build_protocol_info(event_count=1, tool_count=0, schema=schema)
147 assert info.protocol_hash == compute_protocol_hash(schema)
148
149 def test_build_protocol_info_counts(self) -> None:
150 info = build_protocol_info(event_count=12, tool_count=40, schema={})
151 assert info.event_count == 12
152 assert info.tool_count == 40
153
154
155 # ─────────────────────────────────────────────────────────────────────────────
156 # LAYER 2 β€” INTEGRATION
157 # ─────────────────────────────────────────────────────────────────────────────
158
159
160 class TestProtocolIntegration:
161 """Integration: protocol module wires together correctly."""
162
163 def test_musehub_tools_non_empty(self) -> None:
164 assert len(MUSEHUB_TOOLS) > 0
165
166 def test_musehub_tool_names_set_matches_tools_list(self) -> None:
167 names_from_list = {t["name"] for t in MUSEHUB_TOOLS}
168 assert names_from_list == MUSEHUB_TOOL_NAMES
169
170 def test_build_protocol_info_event_count_matches_registry(self) -> None:
171 from musehub.api.routes.protocol import _build_schema
172 schema = _build_schema()
173 info = build_protocol_info(
174 event_count=len(EVENT_REGISTRY),
175 tool_count=len(MUSEHUB_TOOLS),
176 schema=schema,
177 )
178 assert info.event_count == len(EVENT_REGISTRY)
179
180 def test_build_protocol_info_tool_count_matches_catalogue(self) -> None:
181 from musehub.api.routes.protocol import _build_schema
182 schema = _build_schema()
183 info = build_protocol_info(
184 event_count=len(EVENT_REGISTRY),
185 tool_count=len(MUSEHUB_TOOLS),
186 schema=schema,
187 )
188 assert info.tool_count == len(MUSEHUB_TOOLS)
189
190 def test_schema_events_match_registry(self) -> None:
191 from musehub.api.routes.protocol import _build_schema
192 schema = _build_schema()
193 assert set(schema["events"]) == set(EVENT_REGISTRY)
194
195 def test_schema_events_are_sorted(self) -> None:
196 from musehub.api.routes.protocol import _build_schema
197 schema = _build_schema()
198 assert schema["events"] == sorted(EVENT_REGISTRY)
199
200 def test_schema_tools_have_name_and_description(self) -> None:
201 from musehub.api.routes.protocol import _build_schema
202 schema = _build_schema()
203 for tool in schema["tools"]:
204 assert "name" in tool
205 assert "description" in tool
206
207
208 # ─────────────────────────────────────────────────────────────────────────────
209 # LAYER 3 β€” E2E
210 # ─────────────────────────────────────────────────────────────────────────────
211
212
213 class TestProtocolE2E:
214 """E2E: /protocol endpoints via async test client."""
215
216 async def test_get_protocol_returns_200(self, client: AsyncClient) -> None:
217 r = await client.get("/protocol")
218 assert r.status_code == 200
219
220 async def test_get_protocol_json_shape(self, client: AsyncClient) -> None:
221 r = await client.get("/protocol")
222 data = r.json()
223 assert "version" in data
224 assert "protocol_hash" in data
225 assert "event_count" in data
226 assert "tool_count" in data
227
228 async def test_get_protocol_version_matches_muse_version(self, client: AsyncClient) -> None:
229 r = await client.get("/protocol")
230 assert r.json()["version"] == MUSE_VERSION
231
232 async def test_get_protocol_hash_is_canonical_id(self, client: AsyncClient) -> None:
233 r = await client.get("/protocol")
234 h = r.json()["protocol_hash"]
235 assert h.startswith("sha256:")
236 assert len(h) == 71
237
238 async def test_get_protocol_event_count_matches_registry(self, client: AsyncClient) -> None:
239 r = await client.get("/protocol")
240 assert r.json()["event_count"] == len(EVENT_REGISTRY)
241
242 async def test_get_protocol_tool_count_matches_catalogue(self, client: AsyncClient) -> None:
243 r = await client.get("/protocol")
244 assert r.json()["tool_count"] == len(MUSEHUB_TOOLS)
245
246 async def test_get_events_json_returns_200(self, client: AsyncClient) -> None:
247 r = await client.get("/protocol/events.json")
248 assert r.status_code == 200
249
250 async def test_get_events_json_contains_events_key(self, client: AsyncClient) -> None:
251 r = await client.get("/protocol/events.json")
252 assert "events" in r.json()
253
254 async def test_get_events_json_matches_registry(self, client: AsyncClient) -> None:
255 r = await client.get("/protocol/events.json")
256 assert set(r.json()["events"]) == set(EVENT_REGISTRY)
257
258 async def test_get_events_json_sorted(self, client: AsyncClient) -> None:
259 r = await client.get("/protocol/events.json")
260 events = r.json()["events"]
261 assert events == sorted(events)
262
263 async def test_get_tools_json_returns_200(self, client: AsyncClient) -> None:
264 r = await client.get("/protocol/tools.json")
265 assert r.status_code == 200
266
267 async def test_get_tools_json_contains_tools_key(self, client: AsyncClient) -> None:
268 r = await client.get("/protocol/tools.json")
269 assert "tools" in r.json()
270
271 async def test_get_tools_json_count_matches_catalogue(self, client: AsyncClient) -> None:
272 r = await client.get("/protocol/tools.json")
273 assert len(r.json()["tools"]) == len(MUSEHUB_TOOLS)
274
275 async def test_get_tools_json_each_has_name_and_description(self, client: AsyncClient) -> None:
276 r = await client.get("/protocol/tools.json")
277 for tool in r.json()["tools"]:
278 assert "name" in tool
279 assert "description" in tool
280
281 async def test_get_schema_json_returns_200(self, client: AsyncClient) -> None:
282 r = await client.get("/protocol/schema.json")
283 assert r.status_code == 200
284
285 async def test_get_schema_json_shape(self, client: AsyncClient) -> None:
286 r = await client.get("/protocol/schema.json")
287 data = r.json()
288 assert "schema" in data
289 assert "hash" in data
290
291 async def test_get_schema_json_hash_matches_schema(self, client: AsyncClient) -> None:
292 r = await client.get("/protocol/schema.json")
293 data = r.json()
294 expected_hash = compute_protocol_hash(data["schema"])
295 assert data["hash"] == expected_hash
296
297 async def test_protocol_endpoints_no_auth_required(self, client: AsyncClient) -> None:
298 """All /protocol endpoints must be publicly accessible without auth headers."""
299 for path in ("/protocol", "/protocol/events.json", "/protocol/tools.json", "/protocol/schema.json"):
300 r = await client.get(path)
301 assert r.status_code == 200, f"{path} returned {r.status_code}"
302
303
304 # ─────────────────────────────────────────────────────────────────────────────
305 # LAYER 4 β€” STRESS
306 # ─────────────────────────────────────────────────────────────────────────────
307
308
309 class TestProtocolStress:
310 """Stress: repeated calls and bulk hash computation."""
311
312 def test_compute_protocol_hash_1000_times_stable(self) -> None:
313 data = {"events": sorted(EVENT_REGISTRY), "tools": [t["name"] for t in MUSEHUB_TOOLS]}
314 first = compute_protocol_hash(data)
315 for _ in range(1000):
316 assert compute_protocol_hash(data) == first
317
318 def test_compute_protocol_hash_10000_small_dicts(self) -> None:
319 for i in range(10_000):
320 h = compute_protocol_hash({"i": i})
321 assert len(h) == 71
322
323 async def test_get_protocol_50_sequential_requests(self, client: AsyncClient) -> None:
324 hashes = []
325 for _ in range(50):
326 r = await client.get("/protocol")
327 assert r.status_code == 200
328 hashes.append(r.json()["protocol_hash"])
329 assert len(set(hashes)) == 1, "protocol_hash changed between requests"
330
331 async def test_get_events_json_50_sequential_stable(self, client: AsyncClient) -> None:
332 first = None
333 for _ in range(50):
334 r = await client.get("/protocol/events.json")
335 data = r.json()["events"]
336 if first is None:
337 first = data
338 assert data == first
339
340
341 # ─────────────────────────────────────────────────────────────────────────────
342 # LAYER 5 β€” DATA INTEGRITY
343 # ─────────────────────────────────────────────────────────────────────────────
344
345
346 class TestProtocolDataIntegrity:
347 """Data Integrity: schema stability and cross-endpoint consistency."""
348
349 def test_hash_stable_across_module_reloads(self) -> None:
350 """Same schema data always hashes identically regardless of import order."""
351 from musehub.protocol.responses import compute_protocol_hash as h1
352 from musehub.protocol import responses as mod
353 h2 = mod.compute_protocol_hash
354
355 data = {"stable": True, "events": ["a", "b"]}
356 assert h1(data) == h2(data)
357
358 async def test_protocol_hash_equals_schema_hash(self, client: AsyncClient) -> None:
359 """hash in GET /protocol must equal hash in GET /protocol/schema.json."""
360 info_r = await client.get("/protocol")
361 schema_r = await client.get("/protocol/schema.json")
362 assert info_r.json()["protocol_hash"] == schema_r.json()["hash"]
363
364 async def test_events_json_matches_schema_events(self, client: AsyncClient) -> None:
365 events_r = await client.get("/protocol/events.json")
366 schema_r = await client.get("/protocol/schema.json")
367 assert set(events_r.json()["events"]) == set(schema_r.json()["schema"]["events"])
368
369 async def test_tools_json_matches_schema_tools(self, client: AsyncClient) -> None:
370 tools_r = await client.get("/protocol/tools.json")
371 schema_r = await client.get("/protocol/schema.json")
372 tool_names_from_tools = {t["name"] for t in tools_r.json()["tools"]}
373 tool_names_from_schema = {t["name"] for t in schema_r.json()["schema"]["tools"]}
374 assert tool_names_from_tools == tool_names_from_schema
375
376 async def test_event_count_matches_events_list_length(self, client: AsyncClient) -> None:
377 info_r = await client.get("/protocol")
378 events_r = await client.get("/protocol/events.json")
379 assert info_r.json()["event_count"] == len(events_r.json()["events"])
380
381 async def test_tool_count_matches_tools_list_length(self, client: AsyncClient) -> None:
382 info_r = await client.get("/protocol")
383 tools_r = await client.get("/protocol/tools.json")
384 assert info_r.json()["tool_count"] == len(tools_r.json()["tools"])
385
386 def test_no_duplicate_event_types(self) -> None:
387 events = list(EVENT_REGISTRY)
388 assert len(events) == len(set(events))
389
390 def test_no_duplicate_tool_names(self) -> None:
391 names = [t["name"] for t in MUSEHUB_TOOLS]
392 assert len(names) == len(set(names)), "Duplicate tool names in MUSEHUB_TOOLS"
393
394 def test_schema_events_no_duplicates(self) -> None:
395 from musehub.api.routes.protocol import _build_schema
396 schema = _build_schema()
397 assert len(schema["events"]) == len(set(schema["events"]))
398
399
400 # ─────────────────────────────────────────────────────────────────────────────
401 # LAYER 6 β€” SECURITY
402 # ─────────────────────────────────────────────────────────────────────────────
403
404
405 class TestProtocolSecurity:
406 """Security: public endpoints, no sensitive data exposure, injection safety."""
407
408 async def test_protocol_info_no_auth_header_returns_200(self, client: AsyncClient) -> None:
409 r = await client.get("/protocol")
410 assert r.status_code == 200
411
412 async def test_events_json_no_auth_returns_200(self, client: AsyncClient) -> None:
413 r = await client.get("/protocol/events.json")
414 assert r.status_code == 200
415
416 async def test_tools_json_no_auth_returns_200(self, client: AsyncClient) -> None:
417 r = await client.get("/protocol/tools.json")
418 assert r.status_code == 200
419
420 async def test_schema_json_no_auth_returns_200(self, client: AsyncClient) -> None:
421 r = await client.get("/protocol/schema.json")
422 assert r.status_code == 200
423
424 async def test_protocol_no_stack_trace_in_response(self, client: AsyncClient) -> None:
425 r = await client.get("/protocol")
426 text = r.text
427 assert "Traceback" not in text
428 assert "File \"/" not in text
429
430 async def test_tools_json_no_credential_fields(self, client: AsyncClient) -> None:
431 """Tool definitions must not expose internal credentials or private keys."""
432 r = await client.get("/protocol/tools.json")
433 text = r.text.lower()
434 assert "password" not in text
435 assert "private_key" not in text
436 assert "-----begin" not in text # PEM block
437
438 async def test_events_json_content_type_json(self, client: AsyncClient) -> None:
439 r = await client.get("/protocol/events.json")
440 ct = r.headers.get("content-type", "")
441 assert "json" in ct
442
443 async def test_schema_json_content_type_json(self, client: AsyncClient) -> None:
444 r = await client.get("/protocol/schema.json")
445 ct = r.headers.get("content-type", "")
446 assert "json" in ct
447
448 async def test_protocol_post_not_allowed(self, client: AsyncClient) -> None:
449 r = await client.post("/protocol", json={})
450 assert r.status_code in (404, 405)
451
452 def test_compute_hash_does_not_mutate_input(self) -> None:
453 data = {"events": ["a"], "tools": [{"name": "x"}]}
454 original = json.dumps(data)
455 compute_protocol_hash(data)
456 assert json.dumps(data) == original
457
458
459 # ─────────────────────────────────────────────────────────────────────────────
460 # LAYER 7 β€” PERFORMANCE
461 # ─────────────────────────────────────────────────────────────────────────────
462
463
464 class TestProtocolPerformance:
465 """Performance: latency budgets for protocol endpoints and hash computation."""
466
467 def test_compute_protocol_hash_1k_under_100ms(self) -> None:
468 from musehub.api.routes.protocol import _build_schema
469 schema = _build_schema()
470 t0 = time.perf_counter()
471 for _ in range(1000):
472 compute_protocol_hash(schema)
473 elapsed = time.perf_counter() - t0
474 assert elapsed < 0.2, f"1K compute_protocol_hash took {elapsed:.3f}s"
475
476 async def test_get_protocol_under_200ms(self, client: AsyncClient) -> None:
477 t0 = time.perf_counter()
478 r = await client.get("/protocol")
479 elapsed = time.perf_counter() - t0
480 assert r.status_code == 200
481 assert elapsed < 0.2, f"GET /protocol took {elapsed:.3f}s"
482
483 async def test_get_events_json_under_200ms(self, client: AsyncClient) -> None:
484 t0 = time.perf_counter()
485 r = await client.get("/protocol/events.json")
486 elapsed = time.perf_counter() - t0
487 assert r.status_code == 200
488 assert elapsed < 0.2, f"GET /protocol/events.json took {elapsed:.3f}s"
489
490 async def test_get_tools_json_under_300ms(self, client: AsyncClient) -> None:
491 t0 = time.perf_counter()
492 r = await client.get("/protocol/tools.json")
493 elapsed = time.perf_counter() - t0
494 assert r.status_code == 200
495 assert elapsed < 0.3, f"GET /protocol/tools.json took {elapsed:.3f}s"
496
497 async def test_get_schema_json_under_300ms(self, client: AsyncClient) -> None:
498 t0 = time.perf_counter()
499 r = await client.get("/protocol/schema.json")
500 elapsed = time.perf_counter() - t0
501 assert r.status_code == 200
502 assert elapsed < 0.3, f"GET /protocol/schema.json took {elapsed:.3f}s"