gabriel / muse public
test_core_coord_bus.py python
431 lines 19.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for muse.core.coord_bus — coordination bus HTTP client.
2
3 Covers all acceptance criteria:
4
5 Unit:
6 - _build_url: owner/slug %-encoding, path traversal prevention
7 - push_to_hub: request body format, token header, response parsing
8 - pull_from_hub: request body format, cursor, kind filter, limit
9
10 Integration (mock HTTP):
11 - Successful push returns inserted/skipped counts
12 - Successful pull returns records + cursor
13 - 401 produces CoordBusError with "Authentication failed"
14 - 404 produces CoordBusError with status_code=404
15 - Network error produces CoordBusError with status_code=0
16 - Oversized response produces CoordBusError
17
18 Security:
19 - Owner with path traversal (../etc) is %-encoded in URL
20 - Slug with special chars is %-encoded in URL
21 - Redirect refused (status_code propagated)
22 - Token never appears in error messages (401 body masked)
23
24 Validation:
25 - push_to_hub with empty records raises ValueError
26 - push_to_hub with too many records raises ValueError
27 - pull_from_hub with out-of-range limit raises ValueError
28
29 Stress:
30 - 500-record push payload serializes correctly
31 - 1000-record pull response parsed correctly
32 - 100 sequential push_to_hub calls with mock (sub-second)
33 """
34
35 from __future__ import annotations
36
37 import json
38 import time
39 import uuid
40 from contextlib import AbstractContextManager
41 from io import BytesIO
42 from unittest.mock import MagicMock, patch
43
44 import pytest
45
46 from muse.core._types import MsgpackDict
47 from muse.core.coord_bus import (
48 CoordBusError,
49 MAX_PULL_LIMIT,
50 MAX_PUSH_BATCH,
51 _build_url,
52 pull_from_hub,
53 push_to_hub,
54 )
55
56
57 # ── Helpers ────────────────────────────────────────────────────────────────────
58
59
60 def _make_signing() -> "SigningIdentity":
61 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
62 from muse.core.transport import SigningIdentity
63 return SigningIdentity(handle="testuser", private_key=Ed25519PrivateKey.generate())
64
65
66 def _uuid4() -> str:
67 return str(uuid.uuid4())
68
69
70 def _mock_response(body: MsgpackDict, status: int = 200) -> MagicMock:
71 """Build a mock urllib response object for ``_open_url``."""
72 raw = json.dumps(body).encode("utf-8")
73 resp = MagicMock()
74 resp.read.return_value = raw
75 resp.__enter__ = lambda s: s
76 resp.__exit__ = MagicMock(return_value=False)
77 return resp
78
79
80 def _patch_open(body: MsgpackDict, status: int = 200) -> AbstractContextManager[MagicMock]:
81 """Context manager that patches ``_open_url`` in coord_bus."""
82 return patch(
83 "muse.core.coord_bus._STRICT_OPENER.open",
84 return_value=_mock_response(body, status),
85 )
86
87
88 def _make_record(kind: str = "reservation") -> MsgpackDict:
89 return {
90 "kind": kind,
91 "record_uuid": _uuid4(),
92 "run_id": "agent-1",
93 "payload": {"note": "test"},
94 }
95
96
97 # ── Unit: _build_url ───────────────────────────────────────────────────────────
98
99
100 class TestBuildUrl:
101 def test_basic_url(self) -> None:
102 url = _build_url("http://localhost:10003", "gabriel", "myrepo", "coord/push")
103 assert url == "http://localhost:10003/gabriel/myrepo/coord/push"
104
105 def test_trailing_slash_stripped_from_hub(self) -> None:
106 url = _build_url("http://localhost:10003/", "gabriel", "myrepo", "coord/push")
107 assert url == "http://localhost:10003/gabriel/myrepo/coord/push"
108
109 def test_owner_percent_encoded(self) -> None:
110 url = _build_url("http://localhost:10003", "../evil", "myrepo", "coord/push")
111 assert "../evil" not in url
112 assert "%2F" in url or "%2E%2E" in url or "..%2Fevil" in url
113
114 def test_slug_percent_encoded(self) -> None:
115 url = _build_url("http://localhost:10003", "gabriel", "my repo", "coord/push")
116 assert " " not in url
117 assert "my%20repo" in url
118
119 def test_path_traversal_in_owner_encoded(self) -> None:
120 url = _build_url("http://hub", "../../etc", "passwd", "coord/push")
121 # Internal slashes within the owner component MUST be %-encoded so the
122 # owner cannot span multiple path segments (RFC 3986 — %2F ≠ path sep).
123 # The URL structure is /owner/slug/endpoint — only structural '/' chars
124 # are literal; internal ones become %2F.
125 assert "%2F" in url # slashes within owner are encoded
126 # There must be no more than 4 literal slashes: after scheme + host + 3 path seps.
127 path = url.split("://", 1)[1] # strip scheme
128 path_part = path.split("/", 1)[1] if "/" in path else path # strip host
129 # In the path, the ONLY literal '/' must be the owner/slug/endpoint separators.
130 # The traversal '..' must not produce new path segments.
131 assert "..%2F" in url or "%2F.." in url
132
133 def test_special_chars_in_slug(self) -> None:
134 url = _build_url("http://hub", "gabriel", "my@repo", "coord/pull")
135 assert "my%40repo" in url
136
137 def test_push_and_pull_endpoints(self) -> None:
138 push_url = _build_url("http://hub", "u", "r", "coord/push")
139 pull_url = _build_url("http://hub", "u", "r", "coord/pull")
140 assert push_url.endswith("coord/push")
141 assert pull_url.endswith("coord/pull")
142
143
144 # ── Unit: push_to_hub ─────────────────────────────────────────────────────────
145
146
147 class TestPushToHub:
148 def test_push_returns_inserted_skipped(self) -> None:
149 records = [_make_record()]
150 with _patch_open({"inserted": 1, "skipped": 0}):
151 result = push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
152 assert result["inserted"] == 1
153 assert result["skipped"] == 0
154
155 def test_push_empty_records_raises(self) -> None:
156 with pytest.raises(ValueError, match="non-empty"):
157 push_to_hub("http://hub", "gabriel", "repo", [], signing=_make_signing())
158
159 def test_push_too_many_records_raises(self) -> None:
160 records = [_make_record() for _ in range(MAX_PUSH_BATCH + 1)]
161 with pytest.raises(ValueError, match="maximum batch size"):
162 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
163
164 def test_push_sends_authorization_header(self) -> None:
165 records = [_make_record()]
166 with patch("muse.core.coord_bus._STRICT_OPENER.open") as mock_open:
167 mock_open.return_value = _mock_response({"inserted": 1, "skipped": 0})
168 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
169 req = mock_open.call_args[0][0]
170 assert req.get_header("Authorization").startswith("MSign ")
171
172 def test_push_sends_correct_content_type(self) -> None:
173 records = [_make_record()]
174 with patch("muse.core.coord_bus._STRICT_OPENER.open") as mock_open:
175 mock_open.return_value = _mock_response({"inserted": 1, "skipped": 0})
176 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
177 req = mock_open.call_args[0][0]
178 assert "application/json" in req.get_header("Content-type")
179
180 def test_push_request_body_structure(self) -> None:
181 records = [_make_record()]
182 captured = {}
183 with patch("muse.core.coord_bus._STRICT_OPENER.open") as mock_open:
184 mock_open.return_value = _mock_response({"inserted": 1, "skipped": 0})
185 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
186 req = mock_open.call_args[0][0]
187 captured["body"] = json.loads(req.data)
188 assert "records" in captured["body"]
189 assert len(captured["body"]["records"]) == 1
190
191 def test_push_401_raises_with_auth_message(self) -> None:
192 import urllib.error
193 records = [_make_record()]
194 err = urllib.error.HTTPError(
195 "http://hub", 401, "Unauthorized", {}, BytesIO(b"token leaked here")
196 )
197 with patch("muse.core.coord_bus._STRICT_OPENER.open", side_effect=err):
198 with pytest.raises(CoordBusError) as exc_info:
199 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
200 # 401 body must NOT appear in the message (credentials could be reflected).
201 assert "token leaked here" not in str(exc_info.value)
202 assert "Authentication failed" in str(exc_info.value)
203 assert exc_info.value.status_code == 401
204
205 def test_push_404_raises_with_status_code(self) -> None:
206 import urllib.error
207 records = [_make_record()]
208 err = urllib.error.HTTPError("http://hub", 404, "Not Found", {}, BytesIO(b"not found"))
209 with patch("muse.core.coord_bus._STRICT_OPENER.open", side_effect=err):
210 with pytest.raises(CoordBusError) as exc_info:
211 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
212 assert exc_info.value.status_code == 404
213
214 def test_push_network_error_raises(self) -> None:
215 import urllib.error
216 records = [_make_record()]
217 err = urllib.error.URLError("Connection refused")
218 with patch("muse.core.coord_bus._STRICT_OPENER.open", side_effect=err):
219 with pytest.raises(CoordBusError) as exc_info:
220 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
221 assert exc_info.value.status_code == 0
222
223 def test_push_oversized_response_raises(self) -> None:
224 from muse.core.coord_bus import MAX_COORD_RESPONSE_BYTES
225
226 huge_body = b"x" * (MAX_COORD_RESPONSE_BYTES + 2)
227 resp = MagicMock()
228 resp.read.return_value = huge_body
229 resp.__enter__ = lambda s: s
230 resp.__exit__ = MagicMock(return_value=False)
231
232 records = [_make_record()]
233 with patch("muse.core.coord_bus._STRICT_OPENER.open", return_value=resp):
234 with pytest.raises(CoordBusError, match="exceeded"):
235 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
236
237 def test_push_max_batch_exactly_accepted(self) -> None:
238 records = [_make_record() for _ in range(MAX_PUSH_BATCH)]
239 with _patch_open({"inserted": MAX_PUSH_BATCH, "skipped": 0}):
240 result = push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
241 assert result["inserted"] == MAX_PUSH_BATCH
242
243 def test_push_no_token_does_not_send_auth_header(self) -> None:
244 records = [_make_record()]
245 with patch("muse.core.coord_bus._STRICT_OPENER.open") as mock_open:
246 mock_open.return_value = _mock_response({"inserted": 1, "skipped": 0})
247 push_to_hub("http://hub", "gabriel", "repo", records, signing=None)
248 req = mock_open.call_args[0][0]
249 assert req.get_header("Authorization") is None
250
251
252 # ── Unit: pull_from_hub ───────────────────────────────────────────────────────
253
254
255 class TestPullFromHub:
256 def test_pull_returns_records_and_cursor(self) -> None:
257 uid = _uuid4()
258 records = [{"id": 1, "kind": "reservation", "record_uuid": uid, "payload": {}}]
259 with _patch_open({"records": records, "cursor": 1}):
260 result = pull_from_hub("http://hub", "gabriel", "repo", signing=_make_signing())
261 assert len(result["records"]) == 1
262 assert result["cursor"] == 1
263
264 def test_pull_out_of_range_limit_raises(self) -> None:
265 with pytest.raises(ValueError, match="limit must be"):
266 pull_from_hub("http://hub", "gabriel", "repo", limit=0)
267 with pytest.raises(ValueError, match="limit must be"):
268 pull_from_hub("http://hub", "gabriel", "repo", limit=MAX_PULL_LIMIT + 1)
269
270 def test_pull_sends_correct_body(self) -> None:
271 captured = {}
272 with patch("muse.core.coord_bus._STRICT_OPENER.open") as mock_open:
273 mock_open.return_value = _mock_response({"records": [], "cursor": 0})
274 pull_from_hub(
275 "http://hub", "gabriel", "repo",
276 since_id=42,
277 kinds=["reservation"],
278 limit=100,
279 signing=_make_signing(),
280 )
281 req = mock_open.call_args[0][0]
282 captured["body"] = json.loads(req.data)
283 assert captured["body"]["since_id"] == 42
284 assert "reservation" in captured["body"]["kinds"]
285 assert captured["body"]["limit"] == 100
286
287 def test_pull_empty_kinds_sends_empty_list(self) -> None:
288 captured = {}
289 with patch("muse.core.coord_bus._STRICT_OPENER.open") as mock_open:
290 mock_open.return_value = _mock_response({"records": [], "cursor": 0})
291 pull_from_hub("http://hub", "gabriel", "repo", signing=_make_signing())
292 req = mock_open.call_args[0][0]
293 captured["body"] = json.loads(req.data)
294 assert captured["body"]["kinds"] == []
295
296 def test_pull_401_raises_with_masked_body(self) -> None:
297 import urllib.error
298 err = urllib.error.HTTPError(
299 "http://hub", 401, "Unauthorized", {}, BytesIO(b"secret_token_here")
300 )
301 with patch("muse.core.coord_bus._STRICT_OPENER.open", side_effect=err):
302 with pytest.raises(CoordBusError) as exc_info:
303 pull_from_hub("http://hub", "gabriel", "repo", signing=_make_signing())
304 assert "secret_token_here" not in str(exc_info.value)
305 assert exc_info.value.status_code == 401
306
307 def test_pull_network_error(self) -> None:
308 import urllib.error
309 err = urllib.error.URLError("Name resolution failure")
310 with patch("muse.core.coord_bus._STRICT_OPENER.open", side_effect=err):
311 with pytest.raises(CoordBusError) as exc_info:
312 pull_from_hub("http://hub", "gabriel", "repo", signing=_make_signing())
313 assert exc_info.value.status_code == 0
314
315 def test_pull_default_since_id_is_zero(self) -> None:
316 captured = {}
317 with patch("muse.core.coord_bus._STRICT_OPENER.open") as mock_open:
318 mock_open.return_value = _mock_response({"records": [], "cursor": 0})
319 pull_from_hub("http://hub", "gabriel", "repo")
320 req = mock_open.call_args[0][0]
321 captured["body"] = json.loads(req.data)
322 assert captured["body"]["since_id"] == 0
323
324 def test_pull_exact_max_limit_accepted(self) -> None:
325 with _patch_open({"records": [], "cursor": 0}):
326 result = pull_from_hub(
327 "http://hub", "gabriel", "repo", limit=MAX_PULL_LIMIT
328 )
329 assert result["cursor"] == 0
330
331
332 # ── Security tests ─────────────────────────────────────────────────────────────
333
334
335 class TestCoordBusSecurity:
336 def test_redirect_refused(self) -> None:
337 """_NoRedirectHandler refuses all redirects."""
338 import urllib.error
339 from muse.core.coord_bus import _NoRedirectHandler
340
341 handler = _NoRedirectHandler()
342 req_mock = MagicMock()
343 req_mock.full_url = "http://hub/push"
344 fp_mock = MagicMock()
345 headers_mock = MagicMock()
346
347 with pytest.raises(urllib.error.HTTPError) as exc_info:
348 handler.redirect_request(
349 req_mock, fp_mock, 301, "Moved Permanently",
350 headers_mock, "http://other-host/evil"
351 )
352 assert "Redirect refused" in str(exc_info.value.msg)
353
354 def test_401_body_never_exposed(self) -> None:
355 """401 response body must not appear in CoordBusError message."""
356 import urllib.error
357 sensitive = "SENSITIVE_CREDENTIAL_DATA"
358 err = urllib.error.HTTPError("http://hub", 401, "Unauth", {}, BytesIO(sensitive.encode()))
359 records = [_make_record()]
360 with patch("muse.core.coord_bus._STRICT_OPENER.open", side_effect=err):
361 with pytest.raises(CoordBusError) as exc_info:
362 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
363 assert "SENSITIVE_TOKEN" not in str(exc_info.value)
364
365 def test_signing_not_in_url(self) -> None:
366 """Signing identity must appear only in Authorization header, never in the URL."""
367 records = [_make_record()]
368 with patch("muse.core.coord_bus._STRICT_OPENER.open") as mock_open:
369 mock_open.return_value = _mock_response({"inserted": 1, "skipped": 0})
370 si = _make_signing()
371 push_to_hub("http://hub", "gabriel", "repo", records, signing=si)
372 req = mock_open.call_args[0][0]
373 assert "testuser" not in req.full_url
374
375 def test_invalid_json_response_raises(self) -> None:
376 """Non-JSON response body raises CoordBusError, not unhandled exception."""
377 resp = MagicMock()
378 resp.read.return_value = b"not json at all %%"
379 resp.__enter__ = lambda s: s
380 resp.__exit__ = MagicMock(return_value=False)
381 records = [_make_record()]
382 with patch("muse.core.coord_bus._STRICT_OPENER.open", return_value=resp):
383 with pytest.raises(CoordBusError, match="Invalid JSON"):
384 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
385
386
387 # ── Stress tests ───────────────────────────────────────────────────────────────
388
389
390 class TestCoordBusStress:
391 def test_push_500_records_serializes_fast(self) -> None:
392 """500-record batch push serializes and deserializes in < 1 second."""
393 records = [_make_record() for _ in range(500)]
394 t0 = time.monotonic()
395 with _patch_open({"inserted": 500, "skipped": 0}):
396 result = push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
397 elapsed = time.monotonic() - t0
398 assert result["inserted"] == 500
399 assert elapsed < 1.0, f"500-record push took {elapsed:.2f}s"
400
401 def test_pull_1000_records_parsed_fast(self) -> None:
402 """Parsing a 1000-record pull response completes in < 1 second."""
403 uid_list = [_uuid4() for _ in range(1000)]
404 records = [
405 {"id": i + 1, "kind": "reservation", "record_uuid": uid, "payload": {}}
406 for i, uid in enumerate(uid_list)
407 ]
408 t0 = time.monotonic()
409 with _patch_open({"records": records, "cursor": 1000}):
410 result = pull_from_hub("http://hub", "gabriel", "repo", limit=1000)
411 elapsed = time.monotonic() - t0
412 assert len(result["records"]) == 1000
413 assert elapsed < 1.0, f"1000-record pull took {elapsed:.2f}s"
414
415 def test_100_sequential_push_calls_with_mock(self) -> None:
416 """100 sequential push_to_hub calls complete in < 2 seconds with mocked HTTP."""
417 records = [_make_record()]
418 t0 = time.monotonic()
419 for _ in range(100):
420 with _patch_open({"inserted": 1, "skipped": 0}):
421 push_to_hub("http://hub", "gabriel", "repo", records, signing=_make_signing())
422 elapsed = time.monotonic() - t0
423 assert elapsed < 2.0, f"100 sequential pushes took {elapsed:.2f}s"
424
425 def test_build_url_100k_calls_fast(self) -> None:
426 """_build_url is called frequently; 100k calls must complete in < 1s."""
427 t0 = time.monotonic()
428 for _ in range(100_000):
429 _build_url("http://localhost:10003", "gabriel", "myrepo", "coord/push")
430 elapsed = time.monotonic() - t0
431 assert elapsed < 1.0, f"100k _build_url calls took {elapsed:.2f}s"
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago