test_wire_framing.py
python
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