gabriel / muse public
test_transport_fetch_phase2.py python
202 lines 8.8 KB
Raw
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor ⚠ breaking 19 days ago
1 """TDD — HttpTransport.fetch_mpack Phase 2: single presigned-URL protocol (issue #68).
2
3 The Phase 2 server response has exactly four fields — no presign flag, no inline
4 bytes, no pack_urls list. The URL is always the delivery channel.
5
6 Server response shape:
7 {
8 "mpack_url": str, # presigned GET URL; null/absent → up-to-date
9 "mpack_id": str, # sha256:<hex>; null/absent → up-to-date
10 "commit_count": int,
11 "object_count": int,
12 }
13
14 Client protocol (Step 2 from issue #68 spec):
15 1. GET mpack_url (direct to MinIO, bypasses Cloudflare)
16 2. Verify sha256(mpack_bytes).hexdigest() == mpack_id[7:]
17 → abort with TransportError on mismatch (corrupt in transit)
18 3. apply_mpack() on the verified bytes
19
20 Tests:
21 FM-1 Normal fetch: server returns mpack_url + mpack_id, client GETs, sha256
22 matches, FetchMPackResult returned.
23 FM-2 sha256 mismatch on downloaded bytes → TransportError raised immediately.
24 FM-3 Non-200 from GET mpack_url → TransportError.
25 FM-4 Non-200 from POST /fetch → TransportError.
26 FM-5 Server returns mpack_url=null (up-to-date) → empty result, no GET call.
27 FM-6 All objects from the downloaded mpack are present in FetchMPackResult.objects.
28 """
29 from __future__ import annotations
30
31 from unittest.mock import MagicMock, patch
32
33 import msgpack
34 import pytest
35
36 from muse.core.transport import FetchMPackResult, HttpTransport, TransportError
37 from muse.core.types import blob_id, fake_id
38
39 _URL = "https://staging.musehub.ai/gabriel/muse"
40
41
42 # ── helpers ───────────────────────────────────────────────────────────────────
43
44 def _make_mpack(*, n_objects: int = 1, n_commits: int = 1) -> tuple[bytes, str]:
45 """Return (mpack_bytes, mpack_id)."""
46 raws = [f"content-{i}".encode() for i in range(n_objects)]
47 oids = [blob_id(r) for r in raws]
48 commit_ids = [fake_id(f"commit-{i}") for i in range(n_commits)]
49 snap_id = fake_id("snap-0")
50 mpack = {
51 "commits": [
52 {
53 "commit_id": cid, "parent_commit_id": None,
54 "snapshot_id": snap_id, "branch": "main",
55 "message": f"c{i}", "author": "gabriel",
56 }
57 for i, cid in enumerate(commit_ids)
58 ],
59 "snapshots": [{"snapshot_id": snap_id, "manifest": {f"f{i}.bin": oid for i, oid in enumerate(oids)}}],
60 "blobs": [{"object_id": oid, "content": raw} for oid, raw in zip(oids, raws)],
61 }
62 wire = msgpack.packb(mpack, use_bin_type=True)
63 return wire, blob_id(wire)
64
65
66 def _fetch_response(*, mpack_url: str | None, mpack_id: str | None,
67 commit_count: int = 1, object_count: int = 1) -> bytes:
68 """Phase 2 server response — no presign flag, no inline bytes."""
69 return msgpack.packb({
70 "mpack_url": mpack_url,
71 "mpack_id": mpack_id,
72 "commit_count": commit_count,
73 "object_count": object_count,
74 }, use_bin_type=True)
75
76
77 class _Resp:
78 def __init__(self, body: bytes, status: int = 200) -> None:
79 self.status_code = status
80 self.content = body
81
82
83 def _mock_client(post_body: bytes, get_body: bytes | None = None,
84 get_status: int = 200) -> MagicMock:
85 mock = MagicMock()
86 mock.__enter__ = MagicMock(return_value=mock)
87 mock.__exit__ = MagicMock(return_value=False)
88 mock.post = MagicMock(return_value=_Resp(post_body))
89 if get_body is not None:
90 mock.get = MagicMock(return_value=_Resp(get_body, get_status))
91 return mock
92
93
94 # ── FM-1 ──────────────────────────────────────────────────────────────────────
95
96 def test_fm1_normal_fetch_returns_result() -> None:
97 """Server returns mpack_url + mpack_id; client GETs, verifies sha256, returns result."""
98 mpack_bytes, mpack_id = _make_mpack(n_objects=2, n_commits=1)
99 mpack_url = f"https://minio.example.com/mpacks/{mpack_id}?sig=fake"
100
101 mock_client = _mock_client(
102 post_body=_fetch_response(mpack_url=mpack_url, mpack_id=mpack_id,
103 commit_count=1, object_count=2),
104 get_body=mpack_bytes,
105 )
106
107 with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client):
108 result = HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[])
109
110 mock_client.get.assert_called_once_with(mpack_url)
111 assert result["blobs_received"] == 2
112 assert len(result["commits"]) == 1
113
114
115 # ── FM-2 ──────────────────────────────────────────────────────────────────────
116
117 def test_fm2_sha256_mismatch_raises() -> None:
118 """sha256(downloaded_bytes) != mpack_id → TransportError before any apply."""
119 mpack_bytes, mpack_id = _make_mpack()
120 mpack_url = f"https://minio.example.com/mpacks/{mpack_id}?sig=fake"
121
122 tampered = mpack_bytes + b"\xff" # one extra byte breaks the hash
123
124 mock_client = _mock_client(
125 post_body=_fetch_response(mpack_url=mpack_url, mpack_id=mpack_id),
126 get_body=tampered,
127 )
128
129 with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client):
130 with pytest.raises(TransportError, match="integrity"):
131 HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[])
132
133
134 # ── FM-3 ──────────────────────────────────────────────────────────────────────
135
136 def test_fm3_non200_get_raises() -> None:
137 """Non-200 from GET mpack_url → TransportError."""
138 mpack_bytes, mpack_id = _make_mpack()
139 mpack_url = f"https://minio.example.com/mpacks/{mpack_id}?sig=fake"
140
141 mock_client = _mock_client(
142 post_body=_fetch_response(mpack_url=mpack_url, mpack_id=mpack_id),
143 get_body=b"Access Denied",
144 get_status=403,
145 )
146
147 with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client):
148 with pytest.raises(TransportError, match="403"):
149 HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[])
150
151
152 # ── FM-4 ──────────────────────────────────────────────────────────────────────
153
154 def test_fm4_non200_post_raises() -> None:
155 """Non-200 from POST /fetch → TransportError, no GET attempted."""
156 mock = MagicMock()
157 mock.__enter__ = MagicMock(return_value=mock)
158 mock.__exit__ = MagicMock(return_value=False)
159 mock.post = MagicMock(return_value=_Resp(b"Not Found", status=404))
160
161 with patch("muse.core.transport._httpx_mod.Client", return_value=mock):
162 with pytest.raises(TransportError):
163 HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[])
164
165 mock.get.assert_not_called()
166
167
168 # ── FM-5 ──────────────────────────────────────────────────────────────────────
169
170 def test_fm5_null_mpack_url_means_up_to_date() -> None:
171 """Server returns mpack_url=null → client already up-to-date, no GET."""
172 mock_client = _mock_client(
173 post_body=_fetch_response(mpack_url=None, mpack_id=None,
174 commit_count=0, object_count=0),
175 )
176
177 with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client):
178 result = HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[])
179
180 mock_client.get.assert_not_called()
181 assert result["blobs_received"] == 0
182 assert result["commits"] == []
183
184
185 # ── FM-6 ──────────────────────────────────────────────────────────────────────
186
187 def test_fm6_all_objects_present_in_result() -> None:
188 """All objects from the downloaded mpack appear in FetchMPackResult.objects."""
189 mpack_bytes, mpack_id = _make_mpack(n_objects=3)
190 mpack_url = f"https://minio.example.com/mpacks/{mpack_id}?sig=fake"
191
192 mock_client = _mock_client(
193 post_body=_fetch_response(mpack_url=mpack_url, mpack_id=mpack_id, object_count=3),
194 get_body=mpack_bytes,
195 )
196
197 with patch("muse.core.transport._httpx_mod.Client", return_value=mock_client):
198 result = HttpTransport().fetch_mpack(_URL, None, want=[fake_id("w")], have=[])
199
200 assert result["blobs_received"] == 3
201 assert len(result["blobs"]) == 3
202 assert all(isinstance(o, dict) and "object_id" in o for o in result["blobs"])
File History 2 commits
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor 19 days ago
sha256:0bea7600d1eee83e87950be49933b1006fa9dc2c71e7c4ee748d324f61138156 chore: bump version to 0.2.0rc11; fix typing audit violatio… Sonnet 4.6 minor 19 days ago