gabriel / muse public
test_unpack_objects_supercharge.py python
487 lines 19.9 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """SUPERCHARGE tests for ``muse unpack-objects``.
2
3 Coverage tiers
4 --------------
5 - U (Unit): duration_ms / exit_code fields, tags_written field
6 - E (Error routing): JSON errors → stdout when --json; stderr otherwise
7 - S (Schema): error payload has exactly {error, message, duration_ms, exit_code}
8 - D (Data integrity): duration_ms is float >= 0; exit_code 0/1/3 semantics
9 - P (Performance): duration_ms is a sane duration for typical payloads
10 - Sec (Security): no traceback on any error path (mocked write failure)
11 """
12 from __future__ import annotations
13 from collections.abc import Mapping
14
15 import datetime
16 import argparse
17 import json
18 import pathlib
19 from unittest import mock
20
21 import msgpack
22 import pytest
23
24 from muse.core.errors import ExitCode
25 from muse.core.paths import muse_dir, ref_path
26 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
27 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
28 from muse.core.types import Manifest
29 from tests.cli_test_helper import CliRunner, InvokeResult
30
31 runner = CliRunner()
32
33
34 # ---------------------------------------------------------------------------
35 # Helpers (copied from test_mpack_cmd_pack_unpack to stay self-contained)
36 # ---------------------------------------------------------------------------
37
38
39 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
40 repo = tmp_path / "repo"
41 dot_muse = muse_dir(repo)
42 for sub in ("objects", "commits", "snapshots", "refs/heads"):
43 (dot_muse / sub).mkdir(parents=True)
44 (dot_muse / "HEAD").write_text("ref: refs/heads/main")
45 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"}))
46 return repo
47
48
49 def _snap(repo: pathlib.Path, manifest: Manifest | None = None) -> str:
50 m = manifest or {}
51 snap_id = compute_snapshot_id(m)
52 write_snapshot(repo, SnapshotRecord(
53 snapshot_id=snap_id,
54 manifest=m,
55 created_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
56 ))
57 return snap_id
58
59
60 def _commit(repo: pathlib.Path, snap_id: str, *, parent: str | None = None, message: str = "test") -> str:
61 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
62 parent_ids: list[str] = [parent] if parent else []
63 commit_id = compute_commit_id(
64 parent_ids=parent_ids,
65 snapshot_id=snap_id,
66 message=message,
67 committed_at_iso=committed_at.isoformat(),
68 )
69 write_commit(repo, CommitRecord(
70 commit_id=commit_id,
71 repo_id="test-repo",
72 branch="main",
73 snapshot_id=snap_id,
74 message=message,
75 committed_at=committed_at,
76 parent_commit_id=parent,
77 ))
78 return commit_id
79
80
81 def _set_head(repo: pathlib.Path, commit_id: str) -> None:
82 ref_path(repo, "main").write_text(commit_id)
83
84
85 def _po(repo: pathlib.Path, *args: str) -> InvokeResult:
86 from muse.cli.app import main as cli
87 return runner.invoke(cli, ["pack-objects", *args], env={"MUSE_REPO_ROOT": str(repo)})
88
89
90 def _uo(repo: pathlib.Path, input_bytes: bytes, *args: str) -> InvokeResult:
91 from muse.cli.app import main as cli
92 return runner.invoke(cli, ["unpack-objects", *args], input=input_bytes, env={"MUSE_REPO_ROOT": str(repo)})
93
94
95 def _make_pack(tmp_path: pathlib.Path) -> tuple[pathlib.Path, bytes]:
96 """Create a minimal repo and return (repo_path, pack_bytes)."""
97 repo = _make_repo(tmp_path)
98 sid = _snap(repo)
99 cid = _commit(repo, sid)
100 _set_head(repo, cid)
101 pack_bytes = _po(repo, cid).stdout_bytes
102 assert pack_bytes, "pack-objects returned empty bytes"
103 return repo, pack_bytes
104
105
106 # ---------------------------------------------------------------------------
107 # U — Unit: duration_ms and exit_code in success JSON
108 # ---------------------------------------------------------------------------
109
110
111 class TestElapsedMsExitCode:
112 """U1–U5: success JSON always carries duration_ms and exit_code."""
113
114 def test_u1_success_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
115 """U1: duration_ms key present in JSON success response."""
116 src = _make_repo(tmp_path / "src")
117 dst = _make_repo(tmp_path / "dst")
118 sid = _snap(src)
119 cid = _commit(src, sid)
120 pack_bytes = _po(src, cid).stdout_bytes
121 r = _uo(dst, pack_bytes, "--json")
122 assert r.exit_code == 0
123 data = json.loads(r.output)
124 assert "duration_ms" in data, "duration_ms must be present in success JSON"
125
126 def test_u2_success_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
127 """U2: exit_code is 0 in JSON success response."""
128 src = _make_repo(tmp_path / "src")
129 dst = _make_repo(tmp_path / "dst")
130 sid = _snap(src)
131 cid = _commit(src, sid)
132 pack_bytes = _po(src, cid).stdout_bytes
133 r = _uo(dst, pack_bytes, "--json")
134 assert r.exit_code == 0
135 data = json.loads(r.output)
136 assert data["exit_code"] == 0
137
138 def test_u3_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
139 """U3: duration_ms is a float (not int, not string)."""
140 src = _make_repo(tmp_path / "src")
141 dst = _make_repo(tmp_path / "dst")
142 sid = _snap(src)
143 cid = _commit(src, sid)
144 pack_bytes = _po(src, cid).stdout_bytes
145 r = _uo(dst, pack_bytes, "--json")
146 assert r.exit_code == 0
147 data = json.loads(r.output)
148 assert isinstance(data["duration_ms"], float), f"expected float, got {type(data['duration_ms'])}"
149
150 def test_u4_duration_ms_non_negative(self, tmp_path: pathlib.Path) -> None:
151 """U4: duration_ms >= 0."""
152 src = _make_repo(tmp_path / "src")
153 dst = _make_repo(tmp_path / "dst")
154 sid = _snap(src)
155 cid = _commit(src, sid)
156 pack_bytes = _po(src, cid).stdout_bytes
157 r = _uo(dst, pack_bytes, "--json")
158 assert r.exit_code == 0
159 data = json.loads(r.output)
160 assert data["duration_ms"] >= 0.0
161
162 def test_u5_tags_written_in_success_json(self, tmp_path: pathlib.Path) -> None:
163 """U5: tags_written key present in JSON success response."""
164 src = _make_repo(tmp_path / "src")
165 dst = _make_repo(tmp_path / "dst")
166 sid = _snap(src)
167 cid = _commit(src, sid)
168 pack_bytes = _po(src, cid).stdout_bytes
169 r = _uo(dst, pack_bytes, "--json")
170 assert r.exit_code == 0
171 data = json.loads(r.output)
172 assert "tags_written" in data, "tags_written must be present in success JSON"
173 assert isinstance(data["tags_written"], int)
174
175
176 # ---------------------------------------------------------------------------
177 # E — Error routing: JSON errors → stdout when --json, stderr otherwise
178 # ---------------------------------------------------------------------------
179
180
181 class TestJsonErrorsToStdout:
182 """E1–E5: when --json, all errors land on stdout (not stderr)."""
183
184 def test_e1_corrupted_msgpack_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None:
185 """E1: corrupted msgpack with --json → parseable JSON error; stderr is empty.
186
187 r.output is stdout+stderr combined. Proving stderr is empty means everything
188 in r.output came from stdout — so the JSON is on stdout.
189 """
190 repo = _make_repo(tmp_path)
191 r = _uo(repo, b"\xff\xfe corrupted!", "--json")
192 assert r.exit_code != 0
193 assert r.stderr.strip() == "", f"stderr must be empty in JSON mode, got: {r.stderr!r}"
194 data = json.loads(r.output)
195 assert "error" in data
196
197 def test_e2_corrupted_msgpack_error_is_valid_json(self, tmp_path: pathlib.Path) -> None:
198 """E2: corrupted msgpack with --json → output is parseable JSON."""
199 repo = _make_repo(tmp_path)
200 r = _uo(repo, b"\xff\xfe corrupted!", "--json")
201 assert r.exit_code != 0
202 # If this parse fails, the error routing produced non-JSON output
203 data = json.loads(r.output)
204 assert "error" in data
205 assert "message" in data
206
207 def test_e3_not_a_dict_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None:
208 """E3: valid msgpack but not a map → JSON error; stderr is empty."""
209 repo = _make_repo(tmp_path)
210 not_a_map = msgpack.packb(["a", "list", "not", "a", "map"], use_bin_type=True)
211 r = _uo(repo, not_a_map, "--json")
212 assert r.exit_code != 0
213 assert r.stderr.strip() == "", f"stderr must be empty in JSON mode, got: {r.stderr!r}"
214 data = json.loads(r.output)
215 assert "error" in data
216
217 def test_e4_apply_mpack_oserror_exit_3_json_on_stdout(self, tmp_path: pathlib.Path) -> None:
218 """E4: apply_mpack OSError → exit 3, JSON error; stderr is empty."""
219 src = _make_repo(tmp_path / "src")
220 dst = _make_repo(tmp_path / "dst")
221 sid = _snap(src)
222 cid = _commit(src, sid)
223 pack_bytes = _po(src, cid).stdout_bytes
224
225 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("disk full")):
226 r = _uo(dst, pack_bytes, "--json")
227
228 assert r.exit_code == ExitCode.INTERNAL_ERROR
229 assert r.stderr.strip() == "", f"stderr must be empty in JSON mode, got: {r.stderr!r}"
230 data = json.loads(r.output)
231 assert "error" in data
232 assert "disk full" in data.get("message", "")
233
234 def test_e5_apply_mpack_oserror_stderr_empty_in_json_mode(self, tmp_path: pathlib.Path) -> None:
235 """E5: stderr must be empty when apply_mpack raises OSError in JSON mode."""
236 src = _make_repo(tmp_path / "src")
237 dst = _make_repo(tmp_path / "dst")
238 sid = _snap(src)
239 cid = _commit(src, sid)
240 pack_bytes = _po(src, cid).stdout_bytes
241
242 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("disk full")):
243 r = _uo(dst, pack_bytes, "--json")
244
245 assert r.stderr.strip() == "", f"stderr should be empty in JSON mode, got: {r.stderr!r}"
246
247 def test_e6_text_mode_errors_on_stderr(self, tmp_path: pathlib.Path) -> None:
248 """E6: in text mode (no --json), msgpack errors go to stderr (stdout_bytes is empty)."""
249 repo = _make_repo(tmp_path)
250 r = _uo(repo, b"\xff\xfe corrupted!")
251 assert r.exit_code != 0
252 assert r.stdout_bytes == b"", f"stdout_bytes should be empty in text mode, got: {r.stdout_bytes!r}"
253 assert "error" in r.stderr.lower() or "invalid" in r.stderr.lower()
254
255
256 # ---------------------------------------------------------------------------
257 # S — Schema: error payload structure
258 # ---------------------------------------------------------------------------
259
260
261 class TestErrorJsonSchema:
262 """S1–S3: every JSON error has exactly the right keys."""
263
264 def _parse_error(self, r: InvokeResult) -> Mapping[str, object]:
265 return json.loads(r.output)
266
267 def test_s1_corrupted_msgpack_error_schema(self, tmp_path: pathlib.Path) -> None:
268 """S1: corrupted msgpack error has {error, message, duration_ms, exit_code}."""
269 repo = _make_repo(tmp_path)
270 r = _uo(repo, b"\xff\xfe corrupted!", "--json")
271 data = self._parse_error(r)
272 for key in ("error", "message", "duration_ms", "exit_code"):
273 assert key in data, f"missing key {key!r} in error JSON"
274
275 def test_s2_not_a_dict_error_schema(self, tmp_path: pathlib.Path) -> None:
276 """S2: not-a-map error has {error, message, duration_ms, exit_code}."""
277 repo = _make_repo(tmp_path)
278 not_a_map = msgpack.packb(42, use_bin_type=True)
279 r = _uo(repo, not_a_map, "--json")
280 data = self._parse_error(r)
281 for key in ("error", "message", "duration_ms", "exit_code"):
282 assert key in data, f"missing key {key!r} in error JSON"
283
284 def test_s3_apply_mpack_oserror_schema(self, tmp_path: pathlib.Path) -> None:
285 """S3: apply_mpack OSError has {error, message, duration_ms, exit_code}."""
286 src = _make_repo(tmp_path / "src")
287 dst = _make_repo(tmp_path / "dst")
288 sid = _snap(src)
289 cid = _commit(src, sid)
290 pack_bytes = _po(src, cid).stdout_bytes
291
292 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("no space left")):
293 r = _uo(dst, pack_bytes, "--json")
294
295 data = self._parse_error(r)
296 for key in ("error", "message", "duration_ms", "exit_code"):
297 assert key in data, f"missing key {key!r} in error JSON"
298
299 def test_s4_error_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
300 """S4: duration_ms in error JSON is a float >= 0."""
301 repo = _make_repo(tmp_path)
302 r = _uo(repo, b"\xff\xfe corrupted!", "--json")
303 data = self._parse_error(r)
304 assert isinstance(data["duration_ms"], float)
305 assert data["duration_ms"] >= 0.0
306
307 def test_s5_error_exit_code_matches_process_exit(self, tmp_path: pathlib.Path) -> None:
308 """S5: exit_code in JSON matches actual process exit code."""
309 repo = _make_repo(tmp_path)
310 r = _uo(repo, b"\xff\xfe corrupted!", "--json")
311 data = self._parse_error(r)
312 assert data["exit_code"] == r.exit_code
313
314
315 # ---------------------------------------------------------------------------
316 # D — Data integrity
317 # ---------------------------------------------------------------------------
318
319
320 class TestDataIntegrity:
321 """D1–D5: output values are semantically correct."""
322
323 def test_d1_exit_code_1_for_invalid_msgpack_json_mode(self, tmp_path: pathlib.Path) -> None:
324 """D1: corrupted msgpack → exit_code 1 (user error) in JSON."""
325 repo = _make_repo(tmp_path)
326 r = _uo(repo, b"\xff\xfe bad", "--json")
327 assert r.exit_code == ExitCode.USER_ERROR
328
329 def test_d2_exit_code_1_for_not_a_dict_json_mode(self, tmp_path: pathlib.Path) -> None:
330 """D2: msgpack integer → exit_code 1 (user error) in JSON."""
331 repo = _make_repo(tmp_path)
332 not_map = msgpack.packb(99, use_bin_type=True)
333 r = _uo(repo, not_map, "--json")
334 assert r.exit_code == ExitCode.USER_ERROR
335
336 def test_d3_exit_code_3_for_write_failure_json_mode(self, tmp_path: pathlib.Path) -> None:
337 """D3: apply_mpack OSError → exit_code 3 (internal error) in JSON."""
338 src = _make_repo(tmp_path / "src")
339 dst = _make_repo(tmp_path / "dst")
340 sid = _snap(src)
341 cid = _commit(src, sid)
342 pack_bytes = _po(src, cid).stdout_bytes
343
344 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("ENOSPC")):
345 r = _uo(dst, pack_bytes, "--json")
346
347 assert r.exit_code == ExitCode.INTERNAL_ERROR
348
349 def test_d4_tags_written_is_zero_for_tagless_pack(self, tmp_path: pathlib.Path) -> None:
350 """D4: a pack with no tags yields tags_written=0."""
351 src = _make_repo(tmp_path / "src")
352 dst = _make_repo(tmp_path / "dst")
353 sid = _snap(src)
354 cid = _commit(src, sid)
355 pack_bytes = _po(src, cid).stdout_bytes
356 r = _uo(dst, pack_bytes, "--json")
357 assert r.exit_code == 0
358 data = json.loads(r.output)
359 assert data["tags_written"] == 0
360
361 def test_d5_success_json_all_count_fields_present(self, tmp_path: pathlib.Path) -> None:
362 """D5: success JSON has all expected count fields."""
363 src = _make_repo(tmp_path / "src")
364 dst = _make_repo(tmp_path / "dst")
365 sid = _snap(src)
366 cid = _commit(src, sid)
367 pack_bytes = _po(src, cid).stdout_bytes
368 r = _uo(dst, pack_bytes, "--json")
369 assert r.exit_code == 0
370 data = json.loads(r.output)
371 expected_keys = {
372 "commits_written", "snapshots_written", "objects_written",
373 "objects_skipped", "tags_written", "duration_ms", "exit_code",
374 }
375 missing = expected_keys - data.keys()
376 assert not missing, f"missing keys in success JSON: {missing}"
377
378
379 # ---------------------------------------------------------------------------
380 # P — Performance
381 # ---------------------------------------------------------------------------
382
383
384 class TestPerformance:
385 """P1–P2: duration_ms is a realistic duration."""
386
387 def test_p1_duration_ms_under_5000ms_for_single_commit(self, tmp_path: pathlib.Path) -> None:
388 """P1: unpacking a single commit pack finishes in < 5 seconds."""
389 src = _make_repo(tmp_path / "src")
390 dst = _make_repo(tmp_path / "dst")
391 sid = _snap(src)
392 cid = _commit(src, sid)
393 pack_bytes = _po(src, cid).stdout_bytes
394 r = _uo(dst, pack_bytes, "--json")
395 assert r.exit_code == 0
396 data = json.loads(r.output)
397 assert data["duration_ms"] < 5000.0, f"too slow: {data['duration_ms']} ms"
398
399 def test_p2_duration_ms_under_5000ms_for_five_commit_chain(self, tmp_path: pathlib.Path) -> None:
400 """P2: unpacking a 5-commit chain finishes in < 5 seconds."""
401 src = _make_repo(tmp_path / "src")
402 dst = _make_repo(tmp_path / "dst")
403 sid = _snap(src)
404 prev = None
405 last_cid = ""
406 for i in range(5):
407 prev = _commit(src, sid, parent=prev, message=f"commit-{i}")
408 last_cid = prev
409 pack_bytes = _po(src, last_cid).stdout_bytes
410 r = _uo(dst, pack_bytes, "--json")
411 assert r.exit_code == 0
412 data = json.loads(r.output)
413 assert data["duration_ms"] < 5000.0, f"too slow for 5-commit chain: {data['duration_ms']} ms"
414
415
416 # ---------------------------------------------------------------------------
417 # Sec — Security: no traceback on any error path
418 # ---------------------------------------------------------------------------
419
420
421 class TestSecurity:
422 """Sec1–Sec3: error paths must never produce raw Python tracebacks."""
423
424 def test_sec1_no_traceback_on_corrupted_msgpack_json_mode(self, tmp_path: pathlib.Path) -> None:
425 """Sec1: corrupted msgpack with --json → no Traceback in output."""
426 repo = _make_repo(tmp_path)
427 r = _uo(repo, b"\x00\x01\x02 garbage", "--json")
428 assert r.exit_code != 0
429 assert "Traceback" not in r.output
430 assert "Traceback" not in r.stderr
431
432 def test_sec2_no_traceback_on_apply_mpack_oserror(self, tmp_path: pathlib.Path) -> None:
433 """Sec2: mocked write failure with --json → no Traceback in output."""
434 src = _make_repo(tmp_path / "src")
435 dst = _make_repo(tmp_path / "dst")
436 sid = _snap(src)
437 cid = _commit(src, sid)
438 pack_bytes = _po(src, cid).stdout_bytes
439
440 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("permission denied")):
441 r = _uo(dst, pack_bytes, "--json")
442
443 assert r.exit_code != 0
444 assert "Traceback" not in r.output
445 assert "Traceback" not in r.stderr
446
447 def test_sec3_no_traceback_on_apply_mpack_oserror_text_mode(self, tmp_path: pathlib.Path) -> None:
448 """Sec3: mocked write failure in text mode → no Traceback in output."""
449 src = _make_repo(tmp_path / "src")
450 dst = _make_repo(tmp_path / "dst")
451 sid = _snap(src)
452 cid = _commit(src, sid)
453 pack_bytes = _po(src, cid).stdout_bytes
454
455 with mock.patch("muse.cli.commands.unpack_objects.apply_mpack", side_effect=OSError("permission denied")):
456 r = _uo(dst, pack_bytes)
457
458 assert r.exit_code != 0
459 assert "Traceback" not in r.output
460 assert "Traceback" not in r.stderr
461
462
463 # ---------------------------------------------------------------------------
464 # Flag registration
465 # ---------------------------------------------------------------------------
466
467
468 class TestRegisterFlags:
469 def _parse(self, *args: str) -> "argparse.Namespace":
470 import argparse
471 from muse.cli.commands.unpack_objects import register
472 p = argparse.ArgumentParser()
473 sub = p.add_subparsers()
474 register(sub)
475 return p.parse_args(["unpack-objects", *args])
476
477 def test_default_json_out_is_false(self) -> None:
478 ns = self._parse()
479 assert ns.json_out is False
480
481 def test_json_flag_sets_json_out(self) -> None:
482 ns = self._parse("--json")
483 assert ns.json_out is True
484
485 def test_j_shorthand_sets_json_out(self) -> None:
486 ns = self._parse("-j")
487 assert ns.json_out is True
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago