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