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