gabriel / musehub public
test_wire_framing.py python
302 lines 10.9 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Tests for iter_wire_frames — server-side async exact-byte frame reader.
2
3 Test plan:
4 A. Roundtrip — write with MuseWireFrameWriter, read with iter_wire_frames
5 B. Truncated header — clean WireFrameError, not msgpack garbage
6 C. Truncated payload — clean WireFrameError
7 D. Hash mismatch — deterministic WireFrameError
8 E. Size mismatch — envelope sz vs binary length prefix
9 F. Envelope/logical mismatch — verified by caller, not reader
10 (reader yields both; dispatcher checks ft == frame["t"])
11 G. Multiple frames — reader yields them all in order
12 H. Empty stream — reader returns without error
13 """
14 from __future__ import annotations
15
16 import hashlib
17 import struct
18
19 import msgpack
20 import pytest
21
22
23 # ---------------------------------------------------------------------------
24 # Helpers
25 # ---------------------------------------------------------------------------
26
27 def _sha256_oid(data: bytes) -> str:
28 return "sha256:" + hashlib.sha256(data).hexdigest()
29
30
31 def _pack(obj: object) -> bytes:
32 return msgpack.packb(obj, use_bin_type=True)
33
34
35 def _make_frame(ft: str, payload: bytes) -> bytes:
36 """Build one wire frame using MuseWireFrameWriter."""
37 from muse.core.mpack import MuseWireFrameWriter
38 return MuseWireFrameWriter().wrap(frame_type=ft, payload=payload)
39
40
41 async def _body(*chunks: bytes):
42 for c in chunks:
43 yield c
44
45
46 async def _collect(frames_iter) -> list[tuple[dict, bytes]]:
47 result = []
48 async for header, payload in frames_iter:
49 result.append((header, payload))
50 return result
51
52
53 # ---------------------------------------------------------------------------
54 # A — Roundtrip
55 # ---------------------------------------------------------------------------
56
57 class TestRoundtrip:
58 """A. Frames produced by MuseWireFrameWriter are read correctly."""
59
60 @pytest.mark.asyncio
61 async def test_import(self) -> None:
62 from musehub.services.musehub_wire import iter_wire_frames # noqa: F401
63
64 @pytest.mark.asyncio
65 async def test_single_header_frame(self) -> None:
66 from musehub.services.musehub_wire import iter_wire_frames
67
68 payload = _pack({"t": "H", "branch": "main", "n_objects": 0})
69 data = _make_frame("H", payload)
70 frames = await _collect(iter_wire_frames(_body(data)))
71
72 assert len(frames) == 1
73 header, decoded_payload = frames[0]
74 assert header["ft"] == "H"
75 assert decoded_payload == payload
76
77 @pytest.mark.asyncio
78 async def test_multiple_frames_in_sequence(self) -> None:
79 from musehub.services.musehub_wire import iter_wire_frames
80
81 h_payload = _pack({"t": "H", "branch": "main", "n_objects": 0})
82 c_payload = _pack({"t": "C", "commits": [], "snapshots": []})
83 e_payload = _pack({"t": "E", "n_objects": 0, "n_commits": 0})
84
85 data = (
86 _make_frame("H", h_payload)
87 + _make_frame("C", c_payload)
88 + _make_frame("E", e_payload)
89 )
90 frames = await _collect(iter_wire_frames(_body(data)))
91
92 assert len(frames) == 3
93 assert frames[0][0]["ft"] == "H"
94 assert frames[1][0]["ft"] == "C"
95 assert frames[2][0]["ft"] == "E"
96
97 @pytest.mark.asyncio
98 async def test_frames_split_across_chunks(self) -> None:
99 """Reader must handle chunk boundaries that fall mid-frame."""
100 from musehub.services.musehub_wire import iter_wire_frames
101
102 payload = _pack({"t": "H", "branch": "main"})
103 data = _make_frame("H", payload)
104
105 # Split into 3-byte chunks
106 chunks = [data[i:i + 3] for i in range(0, len(data), 3)]
107 frames = await _collect(iter_wire_frames(_body(*chunks)))
108
109 assert len(frames) == 1
110 assert frames[0][0]["ft"] == "H"
111 assert frames[0][1] == payload
112
113 @pytest.mark.asyncio
114 async def test_payload_parses_as_msgpack(self) -> None:
115 from musehub.services.musehub_wire import iter_wire_frames
116
117 payload = _pack({"t": "O", "id": _sha256_oid(b"x"), "content": b"x", "enc": "raw"})
118 data = _make_frame("O", payload)
119 frames = await _collect(iter_wire_frames(_body(data)))
120
121 assert len(frames) == 1
122 decoded = msgpack.unpackb(frames[0][1], raw=False)
123 assert decoded["t"] == "O"
124
125 @pytest.mark.asyncio
126 async def test_header_id_verified(self) -> None:
127 """Reader verifies blob_id(payload) == header['id']."""
128 from musehub.services.musehub_wire import iter_wire_frames
129 from muse.core._types import blob_id
130
131 payload = _pack({"t": "E"})
132 data = _make_frame("E", payload)
133 frames = await _collect(iter_wire_frames(_body(data)))
134 assert blob_id(frames[0][1]) == frames[0][0]["id"]
135
136
137 # ---------------------------------------------------------------------------
138 # H — Empty stream
139 # ---------------------------------------------------------------------------
140
141 class TestEmptyStream:
142 """H. Reader returns immediately on empty body without error."""
143
144 @pytest.mark.asyncio
145 async def test_empty_body_yields_nothing(self) -> None:
146 from musehub.services.musehub_wire import iter_wire_frames
147
148 frames = await _collect(iter_wire_frames(_body()))
149 assert frames == []
150
151 @pytest.mark.asyncio
152 async def test_empty_chunk_yields_nothing(self) -> None:
153 from musehub.services.musehub_wire import iter_wire_frames
154
155 frames = await _collect(iter_wire_frames(_body(b"")))
156 assert frames == []
157
158
159 # ---------------------------------------------------------------------------
160 # B — Truncated header
161 # ---------------------------------------------------------------------------
162
163 class TestTruncatedHeader:
164 """B. Truncation within the envelope header raises WireFrameError."""
165
166 @pytest.mark.asyncio
167 async def test_truncated_at_version_byte(self) -> None:
168 from musehub.services.musehub_wire import iter_wire_frames
169 from muse.core.mpack import WireFrameError
170
171 payload = _pack({"t": "H"})
172 data = _make_frame("H", payload)
173 truncated = data[:5] # magic + version only, no header_len
174
175 with pytest.raises(WireFrameError):
176 await _collect(iter_wire_frames(_body(truncated)))
177
178 @pytest.mark.asyncio
179 async def test_truncated_mid_header_bytes(self) -> None:
180 from musehub.services.musehub_wire import iter_wire_frames
181 from muse.core.mpack import WireFrameError
182
183 payload = _pack({"t": "H"})
184 data = _make_frame("H", payload)
185 # Cut mid-header: after magic(4) + version(1) + header_len(4) + a few header bytes
186 truncated = data[:12]
187
188 with pytest.raises(WireFrameError):
189 await _collect(iter_wire_frames(_body(truncated)))
190
191 @pytest.mark.asyncio
192 async def test_invalid_magic_raises(self) -> None:
193 from musehub.services.musehub_wire import iter_wire_frames
194 from muse.core.mpack import WireFrameError
195
196 payload = _pack({"t": "H"})
197 data = _make_frame("H", payload)
198 # Corrupt the magic
199 bad = b"XXXX" + data[4:]
200
201 with pytest.raises(WireFrameError, match="magic"):
202 await _collect(iter_wire_frames(_body(bad)))
203
204 @pytest.mark.asyncio
205 async def test_wrong_version_raises(self) -> None:
206 from musehub.services.musehub_wire import iter_wire_frames
207 from muse.core.mpack import WireFrameError
208
209 payload = _pack({"t": "H"})
210 data = _make_frame("H", payload)
211 # Replace version byte (index 4) with 0x02 (unsupported)
212 bad = data[:4] + bytes([2]) + data[5:]
213
214 with pytest.raises(WireFrameError, match="version"):
215 await _collect(iter_wire_frames(_body(bad)))
216
217
218 # ---------------------------------------------------------------------------
219 # C — Truncated payload
220 # ---------------------------------------------------------------------------
221
222 class TestTruncatedPayload:
223 """C. Truncation mid-payload raises WireFrameError, not msgpack garbage."""
224
225 @pytest.mark.asyncio
226 async def test_truncated_payload_raises(self) -> None:
227 from musehub.services.musehub_wire import iter_wire_frames
228 from muse.core.mpack import WireFrameError
229
230 payload = _pack({"t": "C", "commits": list(range(50)), "snapshots": []})
231 data = _make_frame("C", payload)
232 # Trim the last 100 bytes of payload
233 truncated = data[:-100]
234
235 with pytest.raises(WireFrameError):
236 await _collect(iter_wire_frames(_body(truncated)))
237
238 @pytest.mark.asyncio
239 async def test_truncation_raises_wire_error_not_msgpack_error(self) -> None:
240 """Truncation must raise WireFrameError, not BufferError or msgpack exceptions."""
241 from musehub.services.musehub_wire import iter_wire_frames
242 from muse.core.mpack import WireFrameError
243
244 payload = _pack({"t": "C", "commits": list(range(20)), "snapshots": []})
245 data = _make_frame("C", payload)
246
247 for cut in [len(data) - 1, len(data) - 50, len(data) - 200]:
248 if cut <= 0:
249 continue
250 with pytest.raises(WireFrameError):
251 await _collect(iter_wire_frames(_body(data[:cut])))
252
253
254 # ---------------------------------------------------------------------------
255 # D — Hash mismatch
256 # ---------------------------------------------------------------------------
257
258 class TestHashMismatch:
259 """D. Tampered payload raises WireFrameError with hash mismatch message."""
260
261 @pytest.mark.asyncio
262 async def test_tampered_payload_raises(self) -> None:
263 from musehub.services.musehub_wire import iter_wire_frames
264 from muse.core.mpack import WireFrameError
265
266 payload = _pack({"t": "C", "commits": [], "snapshots": []})
267 data = _make_frame("C", payload)
268 # Flip the last byte of the payload
269 tampered = data[:-1] + bytes([data[-1] ^ 0xFF])
270
271 with pytest.raises(WireFrameError, match="hash"):
272 await _collect(iter_wire_frames(_body(tampered)))
273
274
275 # ---------------------------------------------------------------------------
276 # E — Size mismatch
277 # ---------------------------------------------------------------------------
278
279 class TestSizeMismatch:
280 """E. Mismatched envelope sz vs binary payload_len raises WireFrameError."""
281
282 @pytest.mark.asyncio
283 async def test_size_mismatch_raises(self) -> None:
284 from musehub.services.musehub_wire import iter_wire_frames
285 from muse.core.mpack import WireFrameError
286 from muse.core._types import blob_id
287
288 payload = _pack({"t": "H"})
289 # Build frame with envelope sz = len(payload) + 50 but actual payload is correct
290 bad_header = {"ft": "H", "id": blob_id(payload), "sz": len(payload) + 50}
291 bad_header_bytes = msgpack.packb(bad_header, use_bin_type=True)
292 tampered = b"".join([
293 b"muse",
294 bytes([1]),
295 struct.pack(">I", len(bad_header_bytes)),
296 bad_header_bytes,
297 struct.pack(">Q", len(payload)),
298 payload,
299 ])
300
301 with pytest.raises(WireFrameError, match="size"):
302 await _collect(iter_wire_frames(_body(tampered)))
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago