gabriel / muse public
test_plumbing_cat_object.py python
495 lines 19.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Comprehensive tests for ``muse plumbing cat-object``.
2
3 Coverage tiers
4 --------------
5 - Unit: _CHUNK constant, _FORMAT_CHOICES
6 - Integration: raw/info formats, --json alias, missing/invalid object_id
7 - Batch: --batch happy path, missing OIDs, mixed, binary, --batch-check,
8 invalid OIDs handled as missing, empty lines skipped, large objects
9 - Security: ANSI in object_id error, path traversal object_id
10 - Stress: 10 MiB object streaming, 200 sequential reads
11 """
12 from __future__ import annotations
13
14 import hashlib
15 import json
16 import pathlib
17
18 from muse.core.errors import ExitCode
19 from muse.core.object_store import write_object
20 from tests.cli_test_helper import CliRunner, InvokeResult
21
22 runner = CliRunner()
23
24
25 # ---------------------------------------------------------------------------
26 # Helpers
27 # ---------------------------------------------------------------------------
28
29 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
30 """Minimal .muse/ structure."""
31 repo = tmp_path / "repo"
32 muse = repo / ".muse"
33 for sub in ("objects", "commits", "snapshots", "refs/heads"):
34 (muse / sub).mkdir(parents=True)
35 (muse / "HEAD").write_text("ref: refs/heads/main")
36 (muse / "repo.json").write_text(json.dumps({"repo_id": "test", "domain": "code"}))
37 return repo
38
39
40 def _store(repo: pathlib.Path, content: bytes) -> str:
41 """Write content to the object store and return its object_id."""
42 oid = hashlib.sha256(content).hexdigest()
43 write_object(repo, oid, content)
44 return oid
45
46
47 def _cat(repo: pathlib.Path, *args: str, stdin: str | bytes | None = None) -> InvokeResult:
48 from muse.cli.app import main as cli
49 return runner.invoke(
50 cli,
51 ["cat-object", *args],
52 env={"MUSE_REPO_ROOT": str(repo)},
53 input=stdin,
54 )
55
56
57 # ---------------------------------------------------------------------------
58 # Unit — module constants
59 # ---------------------------------------------------------------------------
60
61
62 class TestConstants:
63 def test_chunk_size_is_64kib(self) -> None:
64 from muse.cli.commands.plumbing.cat_object import _CHUNK
65 assert _CHUNK == 65536
66
67 def test_format_choices_correct(self) -> None:
68 from muse.cli.commands.plumbing.cat_object import _FORMAT_CHOICES
69 assert "raw" in _FORMAT_CHOICES
70 assert "info" in _FORMAT_CHOICES
71 assert "json" not in _FORMAT_CHOICES
72
73
74 # ---------------------------------------------------------------------------
75 # Integration — raw format (single-object mode)
76 # ---------------------------------------------------------------------------
77
78
79 class TestRawFormat:
80 def test_raw_bytes_match_stored_content(self, tmp_path: pathlib.Path) -> None:
81 repo = _make_repo(tmp_path)
82 content = b"hello object store"
83 oid = _store(repo, content)
84 result = _cat(repo, oid)
85 assert result.exit_code == 0
86 assert result.stdout_bytes == content
87
88 def test_raw_is_default_format(self, tmp_path: pathlib.Path) -> None:
89 repo = _make_repo(tmp_path)
90 content = b"default format"
91 oid = _store(repo, content)
92 result = _cat(repo, oid)
93 assert result.exit_code == 0
94 assert result.stdout_bytes == content
95
96 def test_raw_binary_content_preserved(self, tmp_path: pathlib.Path) -> None:
97 repo = _make_repo(tmp_path)
98 content = bytes(range(256))
99 oid = _store(repo, content)
100 result = _cat(repo, oid)
101 assert result.exit_code == 0
102 assert result.stdout_bytes == content
103
104 def test_raw_empty_object(self, tmp_path: pathlib.Path) -> None:
105 repo = _make_repo(tmp_path)
106 content = b""
107 oid = _store(repo, content)
108 result = _cat(repo, oid)
109 assert result.exit_code == 0
110 assert result.stdout_bytes == content
111
112 def test_explicit_format_raw(self, tmp_path: pathlib.Path) -> None:
113 repo = _make_repo(tmp_path)
114 content = b"explicit raw"
115 oid = _store(repo, content)
116 result = _cat(repo, "--format", "raw", oid)
117 assert result.exit_code == 0
118 assert result.stdout_bytes == content
119
120
121 # ---------------------------------------------------------------------------
122 # Integration — info / --json format (single-object mode)
123 # ---------------------------------------------------------------------------
124
125
126 class TestInfoFormat:
127 def test_info_format_shape(self, tmp_path: pathlib.Path) -> None:
128 repo = _make_repo(tmp_path)
129 content = b"info content"
130 oid = _store(repo, content)
131 result = _cat(repo, "--format", "info", oid)
132 assert result.exit_code == 0
133 data = json.loads(result.output)
134 assert data["object_id"] == oid
135 assert data["present"] is True
136 assert data["size_bytes"] == len(content)
137
138 def test_json_flag_is_alias_for_info(self, tmp_path: pathlib.Path) -> None:
139 repo = _make_repo(tmp_path)
140 content = b"json alias test"
141 oid = _store(repo, content)
142 result = _cat(repo, "--json", oid)
143 assert result.exit_code == 0, f"--json failed: {result.output}"
144 data = json.loads(result.output)
145 assert data["object_id"] == oid
146 assert data["present"] is True
147 assert data["size_bytes"] == len(content)
148
149 def test_info_does_not_emit_content(self, tmp_path: pathlib.Path) -> None:
150 repo = _make_repo(tmp_path)
151 content = b"secret bytes"
152 oid = _store(repo, content)
153 result = _cat(repo, "--format", "info", oid)
154 assert result.exit_code == 0
155 data = json.loads(result.output)
156 assert "object_id" in data
157 assert content not in result.output.encode()
158
159 def test_info_size_matches_actual_file(self, tmp_path: pathlib.Path) -> None:
160 repo = _make_repo(tmp_path)
161 content = b"size check " * 100
162 oid = _store(repo, content)
163 result = _cat(repo, "--json", oid)
164 data = json.loads(result.output)
165 assert data["size_bytes"] == len(content)
166
167 def test_missing_object_info_has_present_false(self, tmp_path: pathlib.Path) -> None:
168 repo = _make_repo(tmp_path)
169 oid = "a" * 64
170 result = _cat(repo, "--format", "info", oid)
171 assert result.exit_code == ExitCode.USER_ERROR
172 data = json.loads(result.output)
173 assert data["present"] is False
174 assert data["size_bytes"] == 0
175
176 def test_json_flag_missing_object_has_present_false(self, tmp_path: pathlib.Path) -> None:
177 repo = _make_repo(tmp_path)
178 oid = "b" * 64
179 result = _cat(repo, "--json", oid)
180 assert result.exit_code == ExitCode.USER_ERROR
181 data = json.loads(result.output)
182 assert data["present"] is False
183
184
185 # ---------------------------------------------------------------------------
186 # Integration — error paths (single-object mode)
187 # ---------------------------------------------------------------------------
188
189
190 class TestErrorPaths:
191 def test_missing_object_raw_errors(self, tmp_path: pathlib.Path) -> None:
192 repo = _make_repo(tmp_path)
193 result = _cat(repo, "c" * 64)
194 assert result.exit_code == ExitCode.USER_ERROR
195
196 def test_invalid_object_id_too_short(self, tmp_path: pathlib.Path) -> None:
197 repo = _make_repo(tmp_path)
198 result = _cat(repo, "abc123")
199 assert result.exit_code == ExitCode.USER_ERROR
200
201 def test_invalid_object_id_uppercase(self, tmp_path: pathlib.Path) -> None:
202 repo = _make_repo(tmp_path)
203 result = _cat(repo, "A" * 64)
204 assert result.exit_code == ExitCode.USER_ERROR
205
206 def test_invalid_object_id_non_hex(self, tmp_path: pathlib.Path) -> None:
207 repo = _make_repo(tmp_path)
208 result = _cat(repo, "z" * 64)
209 assert result.exit_code == ExitCode.USER_ERROR
210
211 def test_invalid_format_errors(self, tmp_path: pathlib.Path) -> None:
212 repo = _make_repo(tmp_path)
213 result = _cat(repo, "--format", "xml", "a" * 64)
214 assert result.exit_code == ExitCode.USER_ERROR
215
216 def test_no_object_id_without_batch_flag_errors(self, tmp_path: pathlib.Path) -> None:
217 repo = _make_repo(tmp_path)
218 result = _cat(repo)
219 assert result.exit_code == ExitCode.USER_ERROR
220
221 def test_no_repo_errors(self, tmp_path: pathlib.Path) -> None:
222 from muse.cli.app import main as cli
223 result = runner.invoke(
224 cli,
225 ["cat-object", "a" * 64],
226 env={"MUSE_REPO_ROOT": str(tmp_path / "no_repo")},
227 )
228 assert result.exit_code != 0
229
230
231 # ---------------------------------------------------------------------------
232 # Batch mode — --batch
233 # ---------------------------------------------------------------------------
234
235
236 class TestBatchMode:
237 def test_batch_single_object_emits_header_and_content(self, tmp_path: pathlib.Path) -> None:
238 repo = _make_repo(tmp_path)
239 content = b"batch content"
240 oid = _store(repo, content)
241 result = _cat(repo, "--batch", stdin=f"{oid}\n")
242 assert result.exit_code == 0
243 raw = result.stdout_bytes
244 # Header: "<oid> blob <size>\n"
245 header_line = f"{oid} blob {len(content)}\n".encode()
246 assert raw.startswith(header_line)
247 # Content follows header, then a trailing newline
248 body = raw[len(header_line):]
249 assert body == content + b"\n"
250
251 def test_batch_missing_oid_emits_missing(self, tmp_path: pathlib.Path) -> None:
252 repo = _make_repo(tmp_path)
253 oid = "d" * 64
254 result = _cat(repo, "--batch", stdin=f"{oid}\n")
255 assert result.exit_code == 0
256 assert result.stdout_bytes == f"{oid} missing\n".encode()
257
258 def test_batch_invalid_oid_emits_missing(self, tmp_path: pathlib.Path) -> None:
259 """Invalid OIDs should produce a 'missing' line, not an error exit."""
260 repo = _make_repo(tmp_path)
261 result = _cat(repo, "--batch", stdin="not-a-valid-oid\n")
262 assert result.exit_code == 0
263 assert b"missing" in result.stdout_bytes
264
265 def test_batch_mixed_present_and_missing(self, tmp_path: pathlib.Path) -> None:
266 repo = _make_repo(tmp_path)
267 c1 = b"first"
268 c2 = b"second"
269 oid1 = _store(repo, c1)
270 oid2 = _store(repo, c2)
271 missing = "e" * 64
272 stdin = f"{oid1}\n{missing}\n{oid2}\n"
273 result = _cat(repo, "--batch", stdin=stdin)
274 assert result.exit_code == 0
275 raw = result.stdout_bytes
276
277 # oid1 present
278 assert f"{oid1} blob {len(c1)}\n".encode() in raw
279 assert c1 in raw
280 # missing
281 assert f"{missing} missing\n".encode() in raw
282 # oid2 present
283 assert f"{oid2} blob {len(c2)}\n".encode() in raw
284 assert c2 in raw
285
286 def test_batch_empty_lines_skipped(self, tmp_path: pathlib.Path) -> None:
287 repo = _make_repo(tmp_path)
288 content = b"hello"
289 oid = _store(repo, content)
290 # stdin has empty lines before and after
291 result = _cat(repo, "--batch", stdin=f"\n\n{oid}\n\n")
292 assert result.exit_code == 0
293 assert f"{oid} blob {len(content)}\n".encode() in result.stdout_bytes
294
295 def test_batch_binary_content_round_trips(self, tmp_path: pathlib.Path) -> None:
296 repo = _make_repo(tmp_path)
297 content = bytes(range(256))
298 oid = _store(repo, content)
299 result = _cat(repo, "--batch", stdin=f"{oid}\n")
300 assert result.exit_code == 0
301 raw = result.stdout_bytes
302 header = f"{oid} blob {len(content)}\n".encode()
303 body = raw[len(header):-1] # strip trailing newline
304 assert body == content
305
306 def test_batch_empty_stdin_produces_no_output(self, tmp_path: pathlib.Path) -> None:
307 repo = _make_repo(tmp_path)
308 result = _cat(repo, "--batch", stdin="")
309 assert result.exit_code == 0
310 assert result.stdout_bytes == b""
311
312 def test_batch_multiple_objects_in_order(self, tmp_path: pathlib.Path) -> None:
313 repo = _make_repo(tmp_path)
314 objects = [(b"alpha", ), (b"beta",), (b"gamma",)]
315 oids = [_store(repo, c[0]) for c in objects]
316 stdin = "\n".join(oids) + "\n"
317 result = _cat(repo, "--batch", stdin=stdin)
318 assert result.exit_code == 0
319 raw = result.stdout_bytes
320 pos = 0
321 for oid, (content,) in zip(oids, objects):
322 header = f"{oid} blob {len(content)}\n".encode()
323 assert raw[pos:pos + len(header)] == header
324 pos += len(header)
325 assert raw[pos:pos + len(content)] == content
326 pos += len(content) + 1 # +1 for trailing '\n'
327
328 def test_batch_mutually_exclusive_with_batch_check(self, tmp_path: pathlib.Path) -> None:
329 repo = _make_repo(tmp_path)
330 result = _cat(repo, "--batch", "--batch-check", stdin="")
331 assert result.exit_code != 0
332
333
334 # ---------------------------------------------------------------------------
335 # Batch-check mode — --batch-check
336 # ---------------------------------------------------------------------------
337
338
339 class TestBatchCheckMode:
340 def test_batch_check_emits_header_only_no_content(self, tmp_path: pathlib.Path) -> None:
341 repo = _make_repo(tmp_path)
342 content = b"check only"
343 oid = _store(repo, content)
344 result = _cat(repo, "--batch-check", stdin=f"{oid}\n")
345 assert result.exit_code == 0
346 raw = result.stdout_bytes
347 expected = f"{oid} blob {len(content)}\n".encode()
348 assert raw == expected
349 # Content bytes must NOT appear
350 assert content not in raw
351
352 def test_batch_check_missing_emits_missing(self, tmp_path: pathlib.Path) -> None:
353 repo = _make_repo(tmp_path)
354 oid = "f" * 64
355 result = _cat(repo, "--batch-check", stdin=f"{oid}\n")
356 assert result.exit_code == 0
357 assert result.stdout_bytes == f"{oid} missing\n".encode()
358
359 def test_batch_check_invalid_oid_emits_missing(self, tmp_path: pathlib.Path) -> None:
360 repo = _make_repo(tmp_path)
361 result = _cat(repo, "--batch-check", stdin="bad\n")
362 assert result.exit_code == 0
363 assert b"missing" in result.stdout_bytes
364
365 def test_batch_check_mixed(self, tmp_path: pathlib.Path) -> None:
366 repo = _make_repo(tmp_path)
367 c1 = b"present"
368 oid1 = _store(repo, c1)
369 missing = "0" * 64
370 result = _cat(repo, "--batch-check", stdin=f"{oid1}\n{missing}\n")
371 assert result.exit_code == 0
372 raw = result.stdout_bytes
373 assert f"{oid1} blob {len(c1)}\n".encode() in raw
374 assert f"{missing} missing\n".encode() in raw
375 # No content bytes
376 assert c1 not in raw
377
378 def test_batch_check_size_accurate(self, tmp_path: pathlib.Path) -> None:
379 repo = _make_repo(tmp_path)
380 content = b"x" * 1000
381 oid = _store(repo, content)
382 result = _cat(repo, "--batch-check", stdin=f"{oid}\n")
383 assert result.exit_code == 0
384 line = result.stdout_bytes.decode()
385 parts = line.strip().split()
386 assert parts[0] == oid
387 assert parts[1] == "blob"
388 assert int(parts[2]) == len(content)
389
390
391 # ---------------------------------------------------------------------------
392 # Security
393 # ---------------------------------------------------------------------------
394
395
396 class TestSecurity:
397 def test_ansi_in_invalid_id_not_in_output(self, tmp_path: pathlib.Path) -> None:
398 repo = _make_repo(tmp_path)
399 evil = "\x1b[31m" + "a" * 60
400 result = _cat(repo, evil)
401 assert result.exit_code == ExitCode.USER_ERROR
402 assert "\x1b" not in result.output
403
404 def test_path_traversal_in_object_id_rejected(self, tmp_path: pathlib.Path) -> None:
405 repo = _make_repo(tmp_path)
406 result = _cat(repo, "../../../etc/passwd")
407 assert result.exit_code == ExitCode.USER_ERROR
408
409 def test_null_byte_in_object_id_rejected(self, tmp_path: pathlib.Path) -> None:
410 repo = _make_repo(tmp_path)
411 result = _cat(repo, "a" * 32 + "\x00" + "b" * 31)
412 assert result.exit_code == ExitCode.USER_ERROR
413
414 def test_no_traceback_on_invalid_id(self, tmp_path: pathlib.Path) -> None:
415 repo = _make_repo(tmp_path)
416 result = _cat(repo, "not-a-valid-id")
417 assert "Traceback" not in result.output
418
419 def test_batch_path_traversal_treated_as_missing(self, tmp_path: pathlib.Path) -> None:
420 """In batch mode, bad OIDs are not errors — they are reported as missing."""
421 repo = _make_repo(tmp_path)
422 result = _cat(repo, "--batch", stdin="../../../etc/passwd\n")
423 assert result.exit_code == 0
424 assert b"missing" in result.stdout_bytes
425
426
427 # ---------------------------------------------------------------------------
428 # Stress
429 # ---------------------------------------------------------------------------
430
431
432 class TestStress:
433 def test_large_object_streams_without_oom(self, tmp_path: pathlib.Path) -> None:
434 repo = _make_repo(tmp_path)
435 content = b"Z" * (10 * 1024 * 1024) # 10 MiB
436 oid = _store(repo, content)
437 result = _cat(repo, oid)
438 assert result.exit_code == 0
439 assert len(result.stdout_bytes) == len(content)
440 assert result.stdout_bytes == content
441
442 def test_large_object_info_is_fast(self, tmp_path: pathlib.Path) -> None:
443 repo = _make_repo(tmp_path)
444 content = b"Y" * (10 * 1024 * 1024)
445 oid = _store(repo, content)
446 result = _cat(repo, "--json", oid)
447 assert result.exit_code == 0
448 data = json.loads(result.output)
449 assert data["size_bytes"] == len(content)
450
451 def test_200_sequential_reads(self, tmp_path: pathlib.Path) -> None:
452 repo = _make_repo(tmp_path)
453 content = b"repeated read"
454 oid = _store(repo, content)
455 for i in range(200):
456 result = _cat(repo, oid)
457 assert result.exit_code == 0, f"failed at iteration {i}"
458 assert result.stdout_bytes == content
459
460 def test_batch_50_objects(self, tmp_path: pathlib.Path) -> None:
461 """50 objects through a single --batch invocation."""
462 repo = _make_repo(tmp_path)
463 pairs: list[tuple[str, bytes]] = []
464 for i in range(50):
465 content = f"object-{i:03d}".encode()
466 oid = _store(repo, content)
467 pairs.append((oid, content))
468 stdin = "\n".join(oid for oid, _ in pairs) + "\n"
469 result = _cat(repo, "--batch", stdin=stdin)
470 assert result.exit_code == 0
471 raw = result.stdout_bytes
472 for oid, content in pairs:
473 assert f"{oid} blob {len(content)}\n".encode() in raw
474 assert content in raw
475
476 def test_batch_check_100_objects(self, tmp_path: pathlib.Path) -> None:
477 """100 objects through --batch-check — no content read."""
478 repo = _make_repo(tmp_path)
479 oids = []
480 sizes = []
481 for i in range(100):
482 content = b"x" * (i + 1)
483 oid = _store(repo, content)
484 oids.append(oid)
485 sizes.append(len(content))
486 stdin = "\n".join(oids) + "\n"
487 result = _cat(repo, "--batch-check", stdin=stdin)
488 assert result.exit_code == 0
489 lines = result.stdout_bytes.decode().strip().splitlines()
490 assert len(lines) == 100
491 for line, oid, size in zip(lines, oids, sizes):
492 parts = line.split()
493 assert parts[0] == oid
494 assert parts[1] == "blob"
495 assert int(parts[2]) == size
File History 3 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:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago