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