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