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