gabriel / muse public
test_cmd_hash_object.py python
430 lines 17.1 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Comprehensive tests for ``muse hash-object``.
2
3 Coverage tiers
4 --------------
5 - Unit: _hash_bytes correctness, _emit output shape
6 - Integration: all flags, stdin mode, --write lifecycle, idempotency
7 - Security: ANSI injection in path errors, path traversal attempt
8 - Stress: large file (streaming), 500 sequential hashes, binary content
9 """
10 from __future__ import annotations
11
12 import json
13 import pathlib
14
15 import pytest
16
17 from muse.core.errors import ExitCode
18 from tests.cli_test_helper import CliRunner, InvokeResult
19 from muse.core._types import blob_id, long_id, split_id
20 from muse.core.object_store import object_path
21
22 runner = CliRunner()
23
24 # ---------------------------------------------------------------------------
25 # Helpers shared across tests
26 # ---------------------------------------------------------------------------
27
28 def _plumb(tmp_path: pathlib.Path, *args: str, stdin: bytes | None = None) -> InvokeResult:
29 from muse.cli.app import main as cli
30 return runner.invoke(cli, ["hash-object", *args], input=stdin)
31
32
33 def _plumb_repo(repo: pathlib.Path, *args: str, stdin: bytes | None = None) -> InvokeResult:
34 from muse.cli.app import main as cli
35 return runner.invoke(
36 cli,
37 ["hash-object", *args],
38 env={"MUSE_REPO_ROOT": str(repo)},
39 input=stdin,
40 )
41
42
43 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
44 """Minimal .muse/ structure."""
45 repo = tmp_path / "repo"
46 muse = repo / ".muse"
47 for sub in ("objects", "commits", "snapshots", "refs/heads"):
48 (muse / sub).mkdir(parents=True)
49 (muse / "HEAD").write_text("ref: refs/heads/main")
50 (muse / "repo.json").write_text(json.dumps({"repo_id": "test", "domain": "code"}))
51 return repo
52
53
54 # ---------------------------------------------------------------------------
55 # Unit — _hash_bytes
56 # ---------------------------------------------------------------------------
57
58
59 class TestHashBytes:
60 def test_known_sha256_empty(self) -> None:
61 from muse.cli.commands.hash_object import _hash_bytes
62 assert _hash_bytes(b"") == blob_id(b"")
63
64 def test_known_sha256_hello_world(self) -> None:
65 from muse.cli.commands.hash_object import _hash_bytes
66 expected = blob_id(b"hello world")
67 assert _hash_bytes(b"hello world") == expected
68
69 def test_deterministic(self) -> None:
70 from muse.cli.commands.hash_object import _hash_bytes
71 data = b"some content " * 100
72 assert _hash_bytes(data) == _hash_bytes(data)
73
74 def test_different_content_different_hash(self) -> None:
75 from muse.cli.commands.hash_object import _hash_bytes
76 assert _hash_bytes(b"a") != _hash_bytes(b"b")
77
78 def test_returns_canonical_prefixed_id(self) -> None:
79 from muse.cli.commands.hash_object import _hash_bytes
80 result = _hash_bytes(b"test")
81 assert result.startswith("sha256:")
82 assert len(result) == 71 # sha256: (7) + 64 hex chars
83 assert all(c in "0123456789abcdef" for c in split_id(result)[1])
84
85
86 class TestEmit:
87 def test_text_format_prints_hash(self, capsys: pytest.CaptureFixture[str]) -> None:
88 from muse.cli.commands.hash_object import _emit
89 from muse.core.timing import start_timer
90 oid = long_id("a" * 64)
91 _emit(False, oid, False, 0, start_timer())
92 out = capsys.readouterr().out.strip()
93 assert out == oid
94
95 def test_json_format_has_fields(self, capsys: pytest.CaptureFixture[str]) -> None:
96 from muse.cli.commands.hash_object import _emit
97 from muse.core.timing import start_timer
98 oid = long_id("b" * 64)
99 _emit(True, oid, True, 42, start_timer())
100 data = json.loads(capsys.readouterr().out)
101 assert data["object_id"] == oid
102 assert data["stored"] is True
103 assert data["size_bytes"] == 42
104 assert "duration_ms" in data
105 assert "exit_code" in data
106
107
108 # ---------------------------------------------------------------------------
109 # Integration — file mode
110 # ---------------------------------------------------------------------------
111
112
113 class TestFileMode:
114 def test_json_output_shape(self, tmp_path: pathlib.Path) -> None:
115 f = tmp_path / "data.txt"
116 f.write_bytes(b"hello world")
117 result = _plumb(tmp_path, "--json", str(f))
118 assert result.exit_code == 0
119 data = json.loads(result.output)
120 assert "object_id" in data
121 assert "stored" in data
122 assert data["object_id"].startswith("sha256:")
123 assert len(data["object_id"]) == 71
124 assert data["stored"] is False
125
126 def test_json_flag_shorthand(self, tmp_path: pathlib.Path) -> None:
127 f = tmp_path / "data.txt"
128 f.write_bytes(b"content")
129 result = _plumb(tmp_path, "--json", str(f))
130 assert result.exit_code == 0
131 data = json.loads(result.output)
132 assert "object_id" in data
133
134 def test_text_format_is_canonical_id(self, tmp_path: pathlib.Path) -> None:
135 f = tmp_path / "data.txt"
136 f.write_bytes(b"test bytes")
137 result = _plumb(tmp_path, str(f))
138 assert result.exit_code == 0
139 raw = result.output.strip()
140 assert raw.startswith("sha256:")
141 assert len(raw) == 71
142
143 def test_text_and_json_same_hash(self, tmp_path: pathlib.Path) -> None:
144 f = tmp_path / "same.txt"
145 f.write_bytes(b"identical content")
146 json_result = _plumb(tmp_path, "--json", str(f))
147 text_result = _plumb(tmp_path, str(f))
148 json_id = json.loads(json_result.output)["object_id"]
149 text_id = text_result.output.strip()
150 assert json_id == text_id
151
152 def test_determinism_same_content_same_hash(self, tmp_path: pathlib.Path) -> None:
153 f1 = tmp_path / "f1.txt"
154 f2 = tmp_path / "f2.txt"
155 f1.write_bytes(b"same bytes")
156 f2.write_bytes(b"same bytes")
157 r1 = json.loads(_plumb(tmp_path, "--json", str(f1)).output)["object_id"]
158 r2 = json.loads(_plumb(tmp_path, "--json", str(f2)).output)["object_id"]
159 assert r1 == r2
160
161 def test_different_content_different_hash(self, tmp_path: pathlib.Path) -> None:
162 f1 = tmp_path / "f1.txt"
163 f2 = tmp_path / "f2.txt"
164 f1.write_bytes(b"alpha")
165 f2.write_bytes(b"beta")
166 r1 = json.loads(_plumb(tmp_path, "--json", str(f1)).output)["object_id"]
167 r2 = json.loads(_plumb(tmp_path, "--json", str(f2)).output)["object_id"]
168 assert r1 != r2
169
170 def test_empty_file(self, tmp_path: pathlib.Path) -> None:
171 f = tmp_path / "empty.txt"
172 f.write_bytes(b"")
173 result = _plumb(tmp_path, "--json", str(f))
174 assert result.exit_code == 0
175 data = json.loads(result.output)
176 assert data["object_id"] == blob_id(b"")
177
178 def test_binary_content(self, tmp_path: pathlib.Path) -> None:
179 f = tmp_path / "binary.bin"
180 f.write_bytes(bytes(range(256)) * 10)
181 result = _plumb(tmp_path, "--json", str(f))
182 assert result.exit_code == 0
183 data = json.loads(result.output)
184 assert data["object_id"].startswith("sha256:")
185 assert len(data["object_id"]) == 71
186
187 def test_missing_file_errors(self, tmp_path: pathlib.Path) -> None:
188 result = _plumb(tmp_path, str(tmp_path / "nonexistent.txt"))
189 assert result.exit_code == ExitCode.USER_ERROR
190
191 def test_directory_as_path_errors(self, tmp_path: pathlib.Path) -> None:
192 result = _plumb(tmp_path, str(tmp_path))
193 assert result.exit_code == ExitCode.USER_ERROR
194
195 def test_no_args_errors(self, tmp_path: pathlib.Path) -> None:
196 result = _plumb(tmp_path)
197 assert result.exit_code != 0
198
199
200 # ---------------------------------------------------------------------------
201 # Integration — --write lifecycle
202 # ---------------------------------------------------------------------------
203
204
205 class TestWrite:
206 def test_write_returns_stored_true(self, tmp_path: pathlib.Path) -> None:
207 repo = _make_repo(tmp_path)
208 f = repo / "sample.txt"
209 f.write_bytes(b"store me")
210 result = _plumb_repo(repo, "--json", "--write", str(f))
211 assert result.exit_code == 0
212 assert json.loads(result.output)["stored"] is True
213
214 def test_write_creates_object_file(self, tmp_path: pathlib.Path) -> None:
215 repo = _make_repo(tmp_path)
216 f = repo / "sample.txt"
217 content = b"store me too"
218 f.write_bytes(content)
219 result = _plumb_repo(repo, "--json", "--write", str(f))
220 data = json.loads(result.output)
221 oid = data["object_id"]
222 obj_file = object_path(repo, oid)
223 assert obj_file.exists()
224 assert obj_file.read_bytes() == content
225
226 def test_write_idempotent_second_call_stored_false(self, tmp_path: pathlib.Path) -> None:
227 repo = _make_repo(tmp_path)
228 f = repo / "dup.txt"
229 f.write_bytes(b"duplicate content")
230 _plumb_repo(repo, "--write", str(f))
231 result2 = _plumb_repo(repo, "--json", "--write", str(f))
232 assert result2.exit_code == 0
233 assert json.loads(result2.output)["stored"] is False
234
235 def test_write_without_repo_errors(self, tmp_path: pathlib.Path) -> None:
236 f = tmp_path / "orphan.txt"
237 f.write_bytes(b"no repo")
238 # Point MUSE_REPO_ROOT at a dir with no .muse/ to force find_repo_root → None
239 result = runner.invoke(
240 __import__("muse.cli.app", fromlist=["main"]).main,
241 ["hash-object", "--write", str(f)],
242 env={"MUSE_REPO_ROOT": str(tmp_path / "no_repo_here")},
243 )
244 assert result.exit_code == ExitCode.USER_ERROR
245
246 def test_write_text_format_still_works(self, tmp_path: pathlib.Path) -> None:
247 repo = _make_repo(tmp_path)
248 f = repo / "text.txt"
249 f.write_bytes(b"text mode write")
250 result = _plumb_repo(repo, "--write", str(f))
251 assert result.exit_code == 0
252 raw = result.output.strip()
253 assert raw.startswith("sha256:")
254 assert len(raw) == 71
255
256
257 # ---------------------------------------------------------------------------
258 # Integration — --stdin mode
259 # ---------------------------------------------------------------------------
260
261
262 class TestStdinMode:
263 def test_stdin_produces_correct_hash(self, tmp_path: pathlib.Path) -> None:
264 content = b"piped content"
265 result = _plumb(tmp_path, "--json", "--stdin", stdin=content)
266 assert result.exit_code == 0
267 data = json.loads(result.output)
268 assert data["object_id"] == blob_id(content)
269 assert data["stored"] is False
270
271 def test_stdin_matches_file_hash(self, tmp_path: pathlib.Path) -> None:
272 content = b"same content"
273 f = tmp_path / "f.txt"
274 f.write_bytes(content)
275 file_result = json.loads(_plumb(tmp_path, "--json", str(f)).output)["object_id"]
276 stdin_result = json.loads(_plumb(tmp_path, "--json", "--stdin", stdin=content).output)["object_id"]
277 assert file_result == stdin_result
278
279 def test_stdin_text_format(self, tmp_path: pathlib.Path) -> None:
280 content = b"text stdin"
281 result = _plumb(tmp_path, "--stdin", stdin=content)
282 assert result.exit_code == 0
283 assert result.output.strip() == blob_id(content)
284
285 def test_stdin_empty_input(self, tmp_path: pathlib.Path) -> None:
286 result = _plumb(tmp_path, "--json", "--stdin", stdin=b"")
287 assert result.exit_code == 0
288 data = json.loads(result.output)
289 assert data["object_id"] == blob_id(b"")
290
291 def test_stdin_and_path_mutually_exclusive(self, tmp_path: pathlib.Path) -> None:
292 f = tmp_path / "f.txt"
293 f.write_bytes(b"x")
294 result = _plumb(tmp_path, "--stdin", str(f))
295 assert result.exit_code == ExitCode.USER_ERROR
296
297 def test_stdin_write_stores_object(self, tmp_path: pathlib.Path) -> None:
298 repo = _make_repo(tmp_path)
299 content = b"stdin stored"
300 result = _plumb_repo(repo, "--json", "--stdin", "--write", stdin=content)
301 assert result.exit_code == 0
302 data = json.loads(result.output)
303 assert data["stored"] is True
304 oid = data["object_id"]
305 obj_file = object_path(repo, oid)
306 assert obj_file.exists()
307
308 def test_stdin_write_without_repo_errors(self, tmp_path: pathlib.Path) -> None:
309 from muse.cli.app import main as cli
310 result = runner.invoke(
311 cli,
312 ["hash-object", "--stdin", "--write"],
313 env={"MUSE_REPO_ROOT": str(tmp_path / "no_repo_here")},
314 input=b"no repo",
315 )
316 assert result.exit_code == ExitCode.USER_ERROR
317
318
319 # ---------------------------------------------------------------------------
320 # Security
321 # ---------------------------------------------------------------------------
322
323
324 class TestSecurity:
325 def test_ansi_in_path_not_in_stderr(self, tmp_path: pathlib.Path) -> None:
326 """A path with embedded ANSI escapes must not reach stderr output."""
327 evil_name = tmp_path / "\x1b[31mevil\x1b[0m.txt"
328 result = _plumb(tmp_path, str(evil_name))
329 assert result.exit_code != 0
330 assert "\x1b" not in result.output
331
332 def test_path_traversal_attempt_outside_repo(self, tmp_path: pathlib.Path) -> None:
333 """/../ in a path is just a filesystem lookup — it either exists or doesn't."""
334 traversal = tmp_path / ".." / "etc" / "passwd"
335 result = _plumb(tmp_path, str(traversal))
336 # If the file doesn't exist, we get USER_ERROR cleanly — not a crash.
337 assert result.exit_code in (0, ExitCode.USER_ERROR)
338
339 def test_no_path_no_stdin_clean_error(self, tmp_path: pathlib.Path) -> None:
340 result = _plumb(tmp_path)
341 assert result.exit_code != 0
342 # Must not be a Python traceback
343 assert "Traceback" not in result.output
344
345 def test_json_output_is_never_a_traceback(self, tmp_path: pathlib.Path) -> None:
346 """Even on error, output must be parseable or stderr-only."""
347 result = _plumb(tmp_path, str(tmp_path / "missing.txt"))
348 assert result.exit_code != 0
349 # stdout should be empty (error went to stderr)
350 assert result.output.strip() == "" or "Traceback" not in result.output
351
352
353 # ---------------------------------------------------------------------------
354 # Stress
355 # ---------------------------------------------------------------------------
356
357
358 class TestStress:
359 def test_large_file_streams_without_oom(self, tmp_path: pathlib.Path) -> None:
360 """A 10 MiB file must hash without loading the full content into memory."""
361 large = tmp_path / "large.bin"
362 chunk = b"X" * 65536 # 64 KiB chunk
363 with large.open("wb") as fh:
364 for _ in range(160): # 160 × 64 KiB = 10 MiB
365 fh.write(chunk)
366 result = _plumb(tmp_path, "--json", str(large))
367 assert result.exit_code == 0
368 data = json.loads(result.output)
369 assert data["object_id"].startswith("sha256:")
370 assert len(data["object_id"]) == 71
371
372 def test_large_file_hash_matches_reference(self, tmp_path: pathlib.Path) -> None:
373 """Chunked hash_file must match a one-shot hashlib computation."""
374 large = tmp_path / "ref.bin"
375 content = bytes(range(256)) * 4096 # 1 MiB, non-repeating byte pattern
376 large.write_bytes(content)
377 result = _plumb(tmp_path, "--json", str(large))
378 expected = blob_id(content)
379 assert json.loads(result.output)["object_id"] == expected
380
381 def test_500_sequential_hashes(self, tmp_path: pathlib.Path) -> None:
382 """500 rapid hash calls must all succeed with consistent results."""
383 f = tmp_path / "stable.txt"
384 f.write_bytes(b"stable content")
385 expected = blob_id(b"stable content")
386 for i in range(500):
387 result = _plumb(tmp_path, "--json", str(f))
388 assert result.exit_code == 0, f"failed at iteration {i}"
389 assert json.loads(result.output)["object_id"] == expected
390
391 def test_stdin_large_binary(self, tmp_path: pathlib.Path) -> None:
392 """Stdin mode handles 1 MiB of binary content correctly."""
393 content = bytes(range(256)) * 4096
394 result = _plumb(tmp_path, "--json", "--stdin", stdin=content)
395 assert result.exit_code == 0
396 assert json.loads(result.output)["object_id"] == blob_id(content)
397
398
399 # ---------------------------------------------------------------------------
400 # TestRegisterFlags — argparse-level verification
401 # ---------------------------------------------------------------------------
402
403
404 class TestRegisterFlags:
405 """Verify that register() wires --json / -j correctly."""
406
407 def _make_parser(self):
408 import argparse
409 from muse.cli.commands.hash_object import register
410 ap = argparse.ArgumentParser()
411 subs = ap.add_subparsers()
412 register(subs)
413 return ap
414
415 def test_json_flag_long(self):
416 ns = self._make_parser().parse_args(["hash-object", "--stdin", "--json"])
417 assert ns.json_out is True
418
419 def test_j_alias(self):
420 ns = self._make_parser().parse_args(["hash-object", "--stdin", "-j"])
421 assert ns.json_out is True
422
423 def test_default_is_text(self):
424 ns = self._make_parser().parse_args(["hash-object", "--stdin"])
425 assert ns.json_out is False
426
427 def test_dest_is_json_out(self):
428 ns = self._make_parser().parse_args(["hash-object", "--stdin", "-j"])
429 assert hasattr(ns, "json_out")
430 assert not hasattr(ns, "fmt")
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago