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