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