gabriel / muse public
test_coord_pull_null_records.py python
658 lines 27.9 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """
2 Tests for the bug: hub response with null/non-list records or null/non-integer
3 cursor causes unhandled TypeError that escapes run_pull as a raw traceback.
4
5 Root cause (coord_bus.py::pull_from_hub):
6
7 return _post_json(url, body, token)
8
9 The raw hub response is returned without validation. run_pull then does:
10
11 pulled_records: list[dict] = result.get("records", [])
12 cursor: int = result.get("cursor", 0)
13
14 When hub returns {"records": null}:
15 - result.get("records", []) → None (key EXISTS, default [] not used)
16 - if pulled_records: → False (None is falsy, so _write_remote_records skipped)
17 - len(pulled_records) → TypeError: object of type 'NoneType' has no len()
18
19 When hub returns {"records": ["a", "b"]} (list of strings not dicts):
20 - _write_remote_records iterates; rec.get("kind") → AttributeError on str
21
22 When hub returns {"cursor": null}:
23 - cursor = None; json.dumps({"cursor": None}) → "cursor": null in output
24 - text mode: f"cursor: {cursor}" → "cursor: None" — contract violated
25
26 When hub returns {"cursor": "malicious"}:
27 - cursor = "malicious"; propagated verbatim to JSON output
28
29 None of these are CoordBusError. run_pull only catches CoordBusError.
30 TypeError/AttributeError escape as raw tracebacks.
31
32 Fix location: pull_from_hub in coord_bus.py — validate response before returning.
33
34 Coverage:
35 Unit — pull_from_hub directly, all bad-value variants
36 Integration — run_pull with bad hub response, two layers deep
37 End-to-end — CLI output is valid JSON with no tracebacks
38 Stress — 50 consecutive pulls mixing good and bad responses
39 Performance — bad response path not slower than good path
40 Security — hub cannot inject arbitrary values into cursor output
41 Data integrity — cursor and count in output reflect reality
42 """
43 from __future__ import annotations
44
45 import argparse
46 import io
47 import itertools
48 import json
49 import pathlib
50 import sys
51 import time
52 from unittest.mock import patch
53
54 import pytest
55
56 from muse.core.types import MsgpackDict, MsgpackValue, content_hash
57 from muse.core.paths import coordination_dir, muse_dir
58
59 # ---------------------------------------------------------------------------
60 # Helpers
61 # ---------------------------------------------------------------------------
62
63 _FUTURE_TS = "2099-12-31T23:59:59+00:00"
64
65 _id_seq = itertools.count()
66
67
68 def _new_id() -> str:
69 return content_hash({"seq": next(_id_seq)})
70
71
72 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
73 muse_dir(tmp_path).mkdir(parents=True, exist_ok=True)
74 return tmp_path
75
76
77 def _good_record(i: int = 0) -> MsgpackDict:
78 return {
79 "kind": "reservation",
80 "record_id": _new_id(),
81 "run_id": f"run-{i}",
82 "payload": {"reservation_id": f"res-{i:06d}", "expires_at": _FUTURE_TS},
83 "expires_at": _FUTURE_TS,
84 }
85
86
87 def _run_pull_with_hub_response(
88 tmp_path: pathlib.Path,
89 hub_response: MsgpackDict,
90 ) -> tuple[int | str | None, str]:
91 """
92 Run run_pull with pull_from_hub mocked to return hub_response.
93 Returns (exit_code, stdout).
94 exit_code is None for clean success, int for SystemExit, "CRASH" for unhandled exception.
95 """
96 root = _make_repo(tmp_path)
97 captured = io.StringIO()
98
99 # Mock _post_json (not pull_from_hub) so the validation in pull_from_hub runs.
100 with patch("muse.core.coord_bus._post_json", return_value=hub_response), \
101 patch("muse.cli.commands.coord_sync.require_repo", return_value=root), \
102 patch("muse.cli.commands.coord_sync._resolve_hub_and_signing",
103 return_value=("https://localhost:1337", "tok")), \
104 patch("sys.stdout", captured):
105 args = argparse.Namespace(
106 owner="torvalds", slug="linux",
107 json_out=True, hub_url=None,
108 since_id=0, limit=1000, kinds=[],
109 )
110 try:
111 from muse.cli.commands.coord_sync import run_pull
112 run_pull(args)
113 except SystemExit as exc:
114 return (exc.code, captured.getvalue())
115 except Exception as exc:
116 return ("CRASH", f"{type(exc).__name__}: {exc}")
117
118 return (None, captured.getvalue())
119
120
121 # =============================================================================
122 # 1. UNIT — pull_from_hub directly
123 # =============================================================================
124
125 class TestPullFromHubNullRecordsUnit:
126 """
127 Unit tests on coord_bus.pull_from_hub.
128 _post_json mocked to return bad responses.
129 Assert CoordBusError is raised, not TypeError/AttributeError.
130 """
131
132 # --- records field ---
133
134 def test_null_records_raises_coord_bus_error(self) -> None:
135 from muse.core.coord_bus import pull_from_hub, CoordBusError
136 with patch("muse.core.coord_bus._post_json",
137 return_value={"records": None, "cursor": 0}):
138 with pytest.raises(CoordBusError):
139 pull_from_hub("https://localhost:1337", "torvalds", "linux")
140
141 def test_null_records_never_raises_raw_typeerror(self) -> None:
142 """The confirmed crash: len(None) must not escape as TypeError."""
143 from muse.core.coord_bus import pull_from_hub, CoordBusError
144 with patch("muse.core.coord_bus._post_json",
145 return_value={"records": None, "cursor": 0}):
146 try:
147 pull_from_hub("https://localhost:1337", "torvalds", "linux")
148 except CoordBusError:
149 pass # correct
150 except TypeError as exc:
151 pytest.fail(f"Raw TypeError escaped pull_from_hub: {exc}")
152
153 @pytest.mark.parametrize("bad_records", [
154 "malicious string",
155 42,
156 3.14,
157 True,
158 {"key": "val"},
159 ])
160 def test_non_list_records_raises_coord_bus_error(self, bad_records: MsgpackValue) -> None:
161 from muse.core.coord_bus import pull_from_hub, CoordBusError
162 with patch("muse.core.coord_bus._post_json",
163 return_value={"records": bad_records, "cursor": 0}):
164 with pytest.raises(CoordBusError):
165 pull_from_hub("https://localhost:1337", "torvalds", "linux")
166
167 def test_list_of_strings_raises_coord_bus_error(self) -> None:
168 """List of strings would cause AttributeError on .get() — must be CoordBusError."""
169 from muse.core.coord_bus import pull_from_hub, CoordBusError
170 with patch("muse.core.coord_bus._post_json",
171 return_value={"records": ["a", "b", "c"], "cursor": 3}):
172 with pytest.raises(CoordBusError):
173 pull_from_hub("https://localhost:1337", "torvalds", "linux")
174
175 def test_list_of_strings_never_raises_raw_attributeerror(self) -> None:
176 from muse.core.coord_bus import pull_from_hub, CoordBusError
177 with patch("muse.core.coord_bus._post_json",
178 return_value={"records": ["a", "b"], "cursor": 2}):
179 try:
180 pull_from_hub("https://localhost:1337", "torvalds", "linux")
181 except CoordBusError:
182 pass
183 except AttributeError as exc:
184 pytest.fail(f"Raw AttributeError escaped pull_from_hub: {exc}")
185
186 def test_missing_records_key_defaults_to_empty_list(self) -> None:
187 """Hub omits records entirely — must default to [] not crash."""
188 from muse.core.coord_bus import pull_from_hub
189 with patch("muse.core.coord_bus._post_json",
190 return_value={"cursor": 0}):
191 result = pull_from_hub("https://localhost:1337", "torvalds", "linux")
192 assert result["records"] == []
193
194 def test_empty_records_list_is_valid(self) -> None:
195 from muse.core.coord_bus import pull_from_hub
196 with patch("muse.core.coord_bus._post_json",
197 return_value={"records": [], "cursor": 0}):
198 result = pull_from_hub("https://localhost:1337", "torvalds", "linux")
199 assert result["records"] == []
200 assert result["cursor"] == 0
201
202 def test_valid_records_list_passes_through(self) -> None:
203 from muse.core.coord_bus import pull_from_hub
204 records = [_good_record(i) for i in range(5)]
205 with patch("muse.core.coord_bus._post_json",
206 return_value={"records": records, "cursor": 5}):
207 result = pull_from_hub("https://localhost:1337", "torvalds", "linux")
208 assert len(result["records"]) == 5
209 assert result["cursor"] == 5
210
211 # --- cursor field ---
212
213 def test_null_cursor_raises_coord_bus_error(self) -> None:
214 from muse.core.coord_bus import pull_from_hub, CoordBusError
215 with patch("muse.core.coord_bus._post_json",
216 return_value={"records": [], "cursor": None}):
217 with pytest.raises(CoordBusError):
218 pull_from_hub("https://localhost:1337", "torvalds", "linux")
219
220 @pytest.mark.parametrize("bad_cursor", [
221 "malicious",
222 [],
223 {},
224 "123abc",
225 ])
226 def test_non_integer_cursor_raises_coord_bus_error(self, bad_cursor: MsgpackValue) -> None:
227 from muse.core.coord_bus import pull_from_hub, CoordBusError
228 with patch("muse.core.coord_bus._post_json",
229 return_value={"records": [], "cursor": bad_cursor}):
230 with pytest.raises(CoordBusError):
231 pull_from_hub("https://localhost:1337", "torvalds", "linux")
232
233 def test_negative_cursor_raises_coord_bus_error(self) -> None:
234 from muse.core.coord_bus import pull_from_hub, CoordBusError
235 with patch("muse.core.coord_bus._post_json",
236 return_value={"records": [], "cursor": -1}):
237 with pytest.raises(CoordBusError):
238 pull_from_hub("https://localhost:1337", "torvalds", "linux")
239
240 def test_missing_cursor_key_defaults_to_zero(self) -> None:
241 from muse.core.coord_bus import pull_from_hub
242 with patch("muse.core.coord_bus._post_json",
243 return_value={"records": []}):
244 result = pull_from_hub("https://localhost:1337", "torvalds", "linux")
245 assert result["cursor"] == 0
246
247 def test_float_cursor_truncated(self) -> None:
248 """Float cursor from hub is truncated to int — no crash."""
249 from muse.core.coord_bus import pull_from_hub
250 with patch("muse.core.coord_bus._post_json",
251 return_value={"records": [], "cursor": 7.9}):
252 result = pull_from_hub("https://localhost:1337", "torvalds", "linux")
253 assert result["cursor"] == 7
254
255 def test_zero_cursor_valid(self) -> None:
256 from muse.core.coord_bus import pull_from_hub
257 with patch("muse.core.coord_bus._post_json",
258 return_value={"records": [], "cursor": 0}):
259 result = pull_from_hub("https://localhost:1337", "torvalds", "linux")
260 assert result["cursor"] == 0
261
262 # --- both null ---
263
264 def test_both_null_raises_coord_bus_error(self) -> None:
265 from muse.core.coord_bus import pull_from_hub, CoordBusError
266 with patch("muse.core.coord_bus._post_json",
267 return_value={"records": None, "cursor": None}):
268 with pytest.raises(CoordBusError):
269 pull_from_hub("https://localhost:1337", "torvalds", "linux")
270
271
272 # =============================================================================
273 # 2. INTEGRATION — run_pull with bad hub response
274 # =============================================================================
275
276 class TestRunPullNullRecordsIntegration:
277 """
278 Integration: run_pull with pull_from_hub mocked at the boundary.
279 Asserts clean exit, no unhandled exceptions, correct JSON structure.
280 """
281
282 def test_null_records_exits_cleanly_not_crash(self, tmp_path: pathlib.Path) -> None:
283 code, output = _run_pull_with_hub_response(
284 tmp_path, {"records": None, "cursor": 0}
285 )
286 assert code != "CRASH", f"run_pull crashed: {output}"
287
288 def test_null_cursor_exits_cleanly_not_crash(self, tmp_path: pathlib.Path) -> None:
289 code, output = _run_pull_with_hub_response(
290 tmp_path, {"records": [], "cursor": None}
291 )
292 assert code != "CRASH", f"run_pull crashed: {output}"
293
294 def test_both_null_exits_cleanly_not_crash(self, tmp_path: pathlib.Path) -> None:
295 code, output = _run_pull_with_hub_response(
296 tmp_path, {"records": None, "cursor": None}
297 )
298 assert code != "CRASH", f"run_pull crashed: {output}"
299
300 def test_string_records_exits_cleanly_not_crash(self, tmp_path: pathlib.Path) -> None:
301 code, output = _run_pull_with_hub_response(
302 tmp_path, {"records": "malicious", "cursor": 0}
303 )
304 assert code != "CRASH", f"run_pull crashed: {output}"
305
306 def test_list_of_strings_exits_cleanly_not_crash(self, tmp_path: pathlib.Path) -> None:
307 code, output = _run_pull_with_hub_response(
308 tmp_path, {"records": ["a", "b", "c"], "cursor": 3}
309 )
310 assert code != "CRASH", f"run_pull crashed: {output}"
311
312 def test_null_records_exits_with_code_1(self, tmp_path: pathlib.Path) -> None:
313 code, output = _run_pull_with_hub_response(
314 tmp_path, {"records": None, "cursor": 0}
315 )
316 assert code == 1, f"expected exit 1 for null records, got {code!r}"
317
318 def test_bad_response_json_output_has_no_traceback(self, tmp_path: pathlib.Path) -> None:
319 _, output = _run_pull_with_hub_response(
320 tmp_path, {"records": None, "cursor": None}
321 )
322 assert "Traceback" not in output
323 assert "TypeError" not in output
324 assert "AttributeError" not in output
325
326 def test_good_response_exits_cleanly(self, tmp_path: pathlib.Path) -> None:
327 records = [_good_record(i) for i in range(3)]
328 code, output = _run_pull_with_hub_response(
329 tmp_path, {"records": records, "cursor": 3}
330 )
331 assert code in (0, None), f"expected clean exit, got {code!r}"
332
333 def test_good_response_output_has_correct_count(self, tmp_path: pathlib.Path) -> None:
334 records = [_good_record(i) for i in range(3)]
335 code, output = _run_pull_with_hub_response(
336 tmp_path, {"records": records, "cursor": 3}
337 )
338 lines = [l for l in output.strip().splitlines() if l.strip()]
339 summary = json.loads(lines[-1])
340 assert summary["count"] == 3
341 assert summary["cursor"] == 3
342
343 def test_empty_records_exits_cleanly(self, tmp_path: pathlib.Path) -> None:
344 code, output = _run_pull_with_hub_response(
345 tmp_path, {"records": [], "cursor": 0}
346 )
347 assert code in (0, None)
348
349 def test_text_mode_null_records_does_not_crash(self, tmp_path: pathlib.Path) -> None:
350 root = _make_repo(tmp_path)
351 with patch("muse.core.coord_bus._post_json",
352 return_value={"records": None, "cursor": 0}), \
353 patch("muse.cli.commands.coord_sync.require_repo", return_value=root), \
354 patch("muse.cli.commands.coord_sync._resolve_hub_and_signing",
355 return_value=("https://localhost:1337", "tok")), \
356 patch("sys.stdout", io.StringIO()):
357 args = argparse.Namespace(
358 owner="torvalds", slug="linux",
359 json_out=False, hub_url=None,
360 since_id=0, limit=1000, kinds=[],
361 )
362 try:
363 from muse.cli.commands.coord_sync import run_pull
364 run_pull(args)
365 except SystemExit:
366 pass
367 except Exception as exc:
368 pytest.fail(f"text mode crashed: {type(exc).__name__}: {exc}")
369
370
371 # =============================================================================
372 # 3. END-TO-END — CLI output is always valid JSON with no exception text
373 # =============================================================================
374
375 class TestRunPullNullRecordsEndToEnd:
376
377 @pytest.mark.parametrize("bad_response", [
378 {"records": None, "cursor": 0},
379 {"records": None, "cursor": None},
380 {"records": [], "cursor": None},
381 {"records": "malicious", "cursor": 0},
382 {"records": 42, "cursor": 0},
383 {"records": ["a", "b"], "cursor": 2},
384 {"records": None},
385 {"cursor": 0},
386 {},
387 ])
388 def test_every_bad_response_produces_valid_json_output(self, tmp_path: pathlib.Path, bad_response: MsgpackDict) -> None:
389 code, output = _run_pull_with_hub_response(tmp_path, bad_response)
390 assert code != "CRASH", f"crashed on {bad_response}: {output}"
391 lines = [l for l in output.strip().splitlines() if l.strip()]
392 assert lines, f"no output for {bad_response}"
393 for line in lines:
394 try:
395 json.loads(line)
396 except json.JSONDecodeError:
397 pytest.fail(f"non-JSON output for {bad_response!r}: {line!r}")
398
399 def test_output_never_contains_exception_class_names(self, tmp_path: pathlib.Path) -> None:
400 for bad in [None, "malicious", [], 42]:
401 _, output = _run_pull_with_hub_response(
402 tmp_path, {"records": bad, "cursor": 0}
403 )
404 for forbidden in ("TypeError", "AttributeError", "Traceback",
405 "most recent call", "ValueError"):
406 assert forbidden not in output, (
407 f"{forbidden!r} leaked for records={bad!r}:\n{output}"
408 )
409
410 def test_output_json_schema_complete_on_bad_response(self, tmp_path: pathlib.Path) -> None:
411 """All required keys present in output even on error."""
412 _, output = _run_pull_with_hub_response(
413 tmp_path, {"records": None, "cursor": 0}
414 )
415 lines = [l for l in output.strip().splitlines() if l.strip()]
416 # At minimum there should be an error line
417 assert lines, "no output at all"
418 # The last line must be parseable JSON
419 summary = json.loads(lines[-1])
420 assert isinstance(summary, dict)
421
422
423 # =============================================================================
424 # 4. STRESS — many pulls mixing good and bad responses
425 # =============================================================================
426
427 class TestRunPullNullRecordsStress:
428
429 def _pull(self, tmp_path_subdir: pathlib.Path, hub_response: MsgpackDict) -> tuple[int | str | None, str]:
430 return _run_pull_with_hub_response(tmp_path_subdir, hub_response)
431
432 def test_50_consecutive_null_records_no_crash(self, tmp_path: pathlib.Path) -> None:
433 for i in range(50):
434 code, output = self._pull(
435 tmp_path / str(i),
436 {"records": None, "cursor": i}
437 )
438 assert code != "CRASH", f"crashed on iteration {i}: {output}"
439
440 def test_alternating_good_and_null_no_crash(self, tmp_path: pathlib.Path) -> None:
441 for i in range(20):
442 if i % 2 == 0:
443 response = {"records": [_good_record(i)], "cursor": i + 1}
444 else:
445 response = {"records": None, "cursor": None}
446 code, output = self._pull(tmp_path / str(i), response)
447 assert code != "CRASH", f"crashed on iteration {i}: {output}"
448
449 def test_all_bad_response_types_in_sequence_no_crash(self, tmp_path: pathlib.Path) -> None:
450 bad_responses = [
451 {"records": None, "cursor": 0},
452 {"records": None, "cursor": None},
453 {"records": "string", "cursor": 0},
454 {"records": 42, "cursor": 0},
455 {"records": True, "cursor": 0},
456 {"records": ["str1", "str2"], "cursor": 2},
457 {"records": {}, "cursor": 0},
458 {"records": [], "cursor": None},
459 {"records": [], "cursor": "bad"},
460 {"records": [], "cursor": -1},
461 ]
462 for i, response in enumerate(bad_responses):
463 code, output = self._pull(tmp_path / str(i), response)
464 assert code != "CRASH", f"crashed on response {response}: {output}"
465 assert code == 1, f"expected exit 1 for {response}, got {code!r}"
466
467
468 # =============================================================================
469 # 5. PERFORMANCE — bad response path overhead is negligible
470 # =============================================================================
471
472 class TestRunPullNullRecordsPerformance:
473
474 def _time_pull(self, tmp_path: pathlib.Path, response: MsgpackDict) -> float:
475 t0 = time.monotonic()
476 _run_pull_with_hub_response(tmp_path, response)
477 return time.monotonic() - t0
478
479 def test_null_response_not_slower_than_good_response(self, tmp_path: pathlib.Path) -> None:
480 # warm up
481 self._time_pull(tmp_path / "w1", {"records": [], "cursor": 0})
482 self._time_pull(tmp_path / "w2", {"records": None, "cursor": 0})
483
484 good = self._time_pull(tmp_path / "g", {"records": [], "cursor": 0})
485 bad = self._time_pull(tmp_path / "b", {"records": None, "cursor": 0})
486
487 assert bad < max(good * 10, 0.100), (
488 f"null response ({bad:.4f}s) unexpectedly slower than good ({good:.4f}s)"
489 )
490
491 def test_50_null_pulls_under_1s(self, tmp_path: pathlib.Path) -> None:
492 t0 = time.monotonic()
493 for i in range(50):
494 _run_pull_with_hub_response(
495 tmp_path / str(i), {"records": None, "cursor": 0}
496 )
497 elapsed = time.monotonic() - t0
498 assert elapsed < 1.0, f"50 null-response pulls took {elapsed:.3f}s (> 1s)"
499
500
501 # =============================================================================
502 # 6. SECURITY — hub cannot inject arbitrary cursor values into output
503 # =============================================================================
504
505 class TestRunPullNullRecordsSecurity:
506
507 @pytest.mark.parametrize("attack_cursor", [
508 "__import__('os').system('id')",
509 "${7*7}",
510 "{{7*7}}",
511 "' OR 1=1 --",
512 "\x00\x01\x02",
513 "9" * 10000,
514 "1e308",
515 "inf",
516 -9999999999,
517 ])
518 def test_attack_cursor_raises_coord_bus_error_not_exec(self, attack_cursor: str | int) -> None:
519 from muse.core.coord_bus import pull_from_hub, CoordBusError
520 with patch("muse.core.coord_bus._post_json",
521 return_value={"records": [], "cursor": attack_cursor}):
522 try:
523 pull_from_hub("https://localhost:1337", "torvalds", "linux")
524 except CoordBusError:
525 pass
526 except Exception as exc:
527 pytest.fail(
528 f"Attack cursor {attack_cursor!r} escaped as "
529 f"{type(exc).__name__}: {exc}"
530 )
531
532 def test_null_cursor_never_appears_in_output_as_none_string(self, tmp_path: pathlib.Path) -> None:
533 """'None' must not appear in CLI output — it means Python None leaked."""
534 _, output = _run_pull_with_hub_response(
535 tmp_path, {"records": [], "cursor": None}
536 )
537 assert '"cursor": null' not in output or True # null is ok in error JSON
538 # What must NOT happen: the Python repr "None" appearing as a string value
539 for line in output.strip().splitlines():
540 if not line.strip():
541 continue
542 parsed = json.loads(line)
543 # If cursor appears in output it must be an int or absent
544 if "cursor" in parsed:
545 assert isinstance(parsed["cursor"], int) or parsed.get("failed"), (
546 f"cursor in output is not an int: {parsed['cursor']!r}"
547 )
548
549 def test_extremely_large_cursor_rejected(self, tmp_path: pathlib.Path) -> None:
550 """Hub claiming cursor=2^63 must not be accepted verbatim."""
551 huge = 2**63
552 code, output = _run_pull_with_hub_response(
553 tmp_path, {"records": [], "cursor": huge}
554 )
555 assert code != "CRASH"
556 lines = [l for l in output.strip().splitlines() if l.strip()]
557 summary = json.loads(lines[-1])
558 if not summary.get("failed"):
559 # If it succeeded, cursor must be sane
560 assert summary.get("cursor", 0) <= 10**15, (
561 f"2^63 cursor accepted verbatim: {summary}"
562 )
563
564
565 # =============================================================================
566 # 7. DATA INTEGRITY — count and cursor in output reflect reality
567 # =============================================================================
568
569 class TestRunPullNullRecordsDataIntegrity:
570
571 def test_count_is_zero_when_records_null(self, tmp_path: pathlib.Path) -> None:
572 """count in output must be 0 (not a crash) when hub returns null records."""
573 code, output = _run_pull_with_hub_response(
574 tmp_path, {"records": None, "cursor": 0}
575 )
576 assert code != "CRASH"
577 # Output must contain some line indicating failure
578 lines = [l for l in output.strip().splitlines() if l.strip()]
579 assert lines
580
581 def test_count_equals_len_of_returned_records(self, tmp_path: pathlib.Path) -> None:
582 records = [_good_record(i) for i in range(7)]
583 _, output = _run_pull_with_hub_response(
584 tmp_path, {"records": records, "cursor": 7}
585 )
586 lines = [l for l in output.strip().splitlines() if l.strip()]
587 summary = json.loads(lines[-1])
588 assert summary["count"] == 7
589
590 def test_cursor_in_output_matches_hub_cursor(self, tmp_path: pathlib.Path) -> None:
591 records = [_good_record(i) for i in range(3)]
592 _, output = _run_pull_with_hub_response(
593 tmp_path, {"records": records, "cursor": 42}
594 )
595 lines = [l for l in output.strip().splitlines() if l.strip()]
596 summary = json.loads(lines[-1])
597 assert summary["cursor"] == 42
598
599 def test_cursor_zero_when_no_records(self, tmp_path: pathlib.Path) -> None:
600 _, output = _run_pull_with_hub_response(
601 tmp_path, {"records": [], "cursor": 0}
602 )
603 lines = [l for l in output.strip().splitlines() if l.strip()]
604 summary = json.loads(lines[-1])
605 assert summary["cursor"] == 0
606
607 def test_records_written_to_disk_on_good_response(self, tmp_path: pathlib.Path) -> None:
608 """Pulled records must actually be written to the remote/ directory."""
609 root = _make_repo(tmp_path)
610 records = [_good_record(i) for i in range(3)]
611
612 with patch("muse.core.coord_bus._post_json",
613 return_value={"records": records, "cursor": 3}), \
614 patch("muse.cli.commands.coord_sync.require_repo", return_value=root), \
615 patch("muse.cli.commands.coord_sync._resolve_hub_and_signing",
616 return_value=("https://localhost:1337", "tok")), \
617 patch("sys.stdout", io.StringIO()):
618 args = argparse.Namespace(
619 owner="torvalds", slug="linux",
620 json_out=True, hub_url=None,
621 since_id=0, limit=1000, kinds=[],
622 )
623 try:
624 from muse.cli.commands.coord_sync import run_pull
625 run_pull(args)
626 except SystemExit:
627 pass
628
629 remote_dir = coordination_dir(root) / "remote"
630 written = list(remote_dir.rglob("*.json"))
631 assert len(written) == 3, f"expected 3 files written, got {len(written)}"
632
633 def test_null_records_writes_nothing_to_disk(self, tmp_path: pathlib.Path) -> None:
634 """When hub returns null records, nothing must be written to remote/."""
635 root = _make_repo(tmp_path)
636
637 with patch("muse.core.coord_bus._post_json",
638 return_value={"records": None, "cursor": 0}), \
639 patch("muse.cli.commands.coord_sync.require_repo", return_value=root), \
640 patch("muse.cli.commands.coord_sync._resolve_hub_and_signing",
641 return_value=("https://localhost:1337", "tok")), \
642 patch("sys.stdout", io.StringIO()):
643 args = argparse.Namespace(
644 owner="torvalds", slug="linux",
645 json_out=True, hub_url=None,
646 since_id=0, limit=1000, kinds=[],
647 )
648 try:
649 from muse.cli.commands.coord_sync import run_pull
650 run_pull(args)
651 except SystemExit:
652 pass
653 except Exception:
654 pass
655
656 remote_dir = coordination_dir(root) / "remote"
657 written = list(remote_dir.rglob("*.json")) if remote_dir.exists() else []
658 assert written == [], f"null records caused {len(written)} files to be written"
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago