gabriel / muse public
test_cmd_show_ref_hardening.py python
236 lines 9.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Hardening tests for ``muse show-ref``.
2
3 Gaps closed
4 -----------
5 1. ``duration_ms`` + ``exit_code`` absent from ALL JSON output paths
6 (listing, ``--head``, ``--count``, ``--verify``).
7 2. Format error wrote JSON to stderr — inconsistent with the agent error
8 pattern: should be plain text to stderr (fmt is unknown, so we can't
9 assume JSON was desired).
10 3. I/O error on listing wrote JSON to stderr — should use _emit_error().
11 4. ``_ShowRefResult`` TypedDict missing ``duration_ms`` / ``exit_code``.
12 5. Module docstring missing envelope fields and error contract.
13 """
14
15 from __future__ import annotations
16
17 import json
18 import pathlib
19
20 import pytest
21
22 from muse.core._types import long_id
23 from tests.cli_test_helper import CliRunner, InvokeResult
24
25 runner = CliRunner()
26
27 _VALID_OID = long_id("a" * 64)
28
29
30 # ---------------------------------------------------------------------------
31 # Helpers
32 # ---------------------------------------------------------------------------
33
34
35 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
36 repo = tmp_path / "repo"
37 muse = repo / ".muse"
38 (muse / "objects").mkdir(parents=True)
39 (muse / "commits").mkdir(parents=True)
40 (muse / "snapshots").mkdir(parents=True)
41 (muse / "refs" / "heads").mkdir(parents=True)
42 (muse / "HEAD").write_text("ref: refs/heads/main")
43 (muse / "repo.json").write_text(json.dumps({"repo_id": "r1", "domain": "code"}))
44 return repo
45
46
47 def _write_ref(repo: pathlib.Path, branch: str, commit_id: str = _VALID_OID) -> None:
48 (repo / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
49
50
51 def _sr(repo: pathlib.Path, *args: str) -> InvokeResult:
52 from muse.cli.app import main as cli
53 return runner.invoke(cli, ["show-ref", *args],
54 env={"MUSE_REPO_ROOT": str(repo)})
55
56
57 def _assert_has_envelope(data: dict) -> None:
58 assert "duration_ms" in data, f"'duration_ms' missing: {list(data)}"
59 assert "exit_code" in data, f"'exit_code' missing: {list(data)}"
60 assert isinstance(data["duration_ms"], float)
61 assert data["duration_ms"] >= 0.0
62 assert data["exit_code"] == 0
63
64
65 # ---------------------------------------------------------------------------
66 # TestElapsedAndExitCode — every JSON output path must carry the envelope
67 # ---------------------------------------------------------------------------
68
69
70 class TestElapsedAndExitCode:
71 def test_listing_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
72 repo = _make_repo(tmp_path)
73 data = json.loads(_sr(repo).output)
74 assert "duration_ms" in data
75
76 def test_listing_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
77 repo = _make_repo(tmp_path)
78 data = json.loads(_sr(repo).output)
79 assert isinstance(data["duration_ms"], float)
80 assert data["duration_ms"] >= 0.0
81
82 def test_listing_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
83 repo = _make_repo(tmp_path)
84 data = json.loads(_sr(repo).output)
85 assert data["exit_code"] == 0
86
87 def test_listing_with_refs_has_envelope(self, tmp_path: pathlib.Path) -> None:
88 repo = _make_repo(tmp_path)
89 _write_ref(repo, "main")
90 data = json.loads(_sr(repo).output)
91 _assert_has_envelope(data)
92
93 def test_count_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
94 repo = _make_repo(tmp_path)
95 data = json.loads(_sr(repo, "--count").output)
96 assert "duration_ms" in data
97
98 def test_count_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
99 repo = _make_repo(tmp_path)
100 data = json.loads(_sr(repo, "--count").output)
101 assert data["exit_code"] == 0
102
103 def test_count_json_has_count_field(self, tmp_path: pathlib.Path) -> None:
104 """Adding envelope must not drop the count field."""
105 repo = _make_repo(tmp_path)
106 _write_ref(repo, "main")
107 data = json.loads(_sr(repo, "--count").output)
108 assert data["count"] == 1
109
110 def test_head_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
111 repo = _make_repo(tmp_path)
112 _write_ref(repo, "main")
113 data = json.loads(_sr(repo, "--head").output)
114 assert "duration_ms" in data
115
116 def test_head_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
117 repo = _make_repo(tmp_path)
118 _write_ref(repo, "main")
119 data = json.loads(_sr(repo, "--head").output)
120 assert data["exit_code"] == 0
121
122 def test_head_null_json_has_envelope(self, tmp_path: pathlib.Path) -> None:
123 """Even when HEAD has no commit, the envelope must be present."""
124 repo = _make_repo(tmp_path)
125 data = json.loads(_sr(repo, "--head").output)
126 assert "duration_ms" in data
127 assert "exit_code" in data
128
129 def test_verify_exists_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
130 repo = _make_repo(tmp_path)
131 _write_ref(repo, "main")
132 data = json.loads(_sr(repo, "--verify", "refs/heads/main").output)
133 assert "duration_ms" in data
134
135 def test_verify_exists_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
136 repo = _make_repo(tmp_path)
137 _write_ref(repo, "main")
138 data = json.loads(_sr(repo, "--verify", "refs/heads/main").output)
139 assert data["exit_code"] == 0
140
141 def test_verify_not_exists_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
142 repo = _make_repo(tmp_path)
143 data = json.loads(_sr(repo, "--verify", "refs/heads/ghost").output)
144 assert "duration_ms" in data
145
146 def test_verify_not_exists_json_has_exit_code_nonzero(
147 self, tmp_path: pathlib.Path
148 ) -> None:
149 repo = _make_repo(tmp_path)
150 result = _sr(repo, "--verify", "refs/heads/ghost")
151 data = json.loads(result.output)
152 assert data["exit_code"] == result.exit_code
153 assert data["exit_code"] != 0
154
155
156 # ---------------------------------------------------------------------------
157 # TestErrorJson — format error must be plain text to stderr, not JSON to stderr
158 # ---------------------------------------------------------------------------
159
160
161 class TestErrorJson:
162 def test_bad_format_stdout_is_empty(self, tmp_path: pathlib.Path) -> None:
163 """Format error must not bleed anything to stdout."""
164 repo = _make_repo(tmp_path)
165 result = _sr(repo, "--format", "yaml")
166 assert result.exit_code != 0
167 assert result.stdout_bytes == b""
168
169 def test_bad_format_stderr_has_message(self, tmp_path: pathlib.Path) -> None:
170 """Format error message goes to stderr as plain text."""
171 repo = _make_repo(tmp_path)
172 result = _sr(repo, "--format", "yaml")
173 assert result.stderr # must not be empty
174 assert "\x1b[" not in result.stderr # no ANSI escapes
175
176 def test_bad_format_stderr_is_not_json(self, tmp_path: pathlib.Path) -> None:
177 """Format error should NOT be a JSON blob on stderr."""
178 repo = _make_repo(tmp_path)
179 result = _sr(repo, "--format", "yaml")
180 # Plain-text error: stderr should not parse as JSON
181 try:
182 json.loads(result.stderr)
183 is_json = True
184 except (json.JSONDecodeError, ValueError):
185 is_json = False
186 assert not is_json, f"stderr should be plain text, got JSON: {result.stderr!r}"
187
188
189 # ---------------------------------------------------------------------------
190 # TestRequiredKeysUpdated — listing JSON schema includes envelope fields
191 # ---------------------------------------------------------------------------
192
193
194 class TestRequiredKeysUpdated:
195 REQUIRED_KEYS = {"refs", "head", "count", "duration_ms", "exit_code"}
196
197 def test_listing_schema_complete(self, tmp_path: pathlib.Path) -> None:
198 repo = _make_repo(tmp_path)
199 data = json.loads(_sr(repo).output)
200 missing = self.REQUIRED_KEYS - set(data)
201 assert not missing, f"Missing JSON keys: {missing}"
202
203
204 # ---------------------------------------------------------------------------
205 # TestValidOidFormat — refs written with sha256: prefix are listed correctly
206 # ---------------------------------------------------------------------------
207
208
209 class TestValidOidFormat:
210 def test_sha256_prefixed_oid_appears_in_listing(
211 self, tmp_path: pathlib.Path
212 ) -> None:
213 """Only sha256:-prefixed OIDs are valid; bare hex is rejected."""
214 repo = _make_repo(tmp_path)
215 _write_ref(repo, "main", _VALID_OID)
216 data = json.loads(_sr(repo).output)
217 assert data["count"] == 1
218 assert data["refs"][0]["commit_id"] == _VALID_OID
219
220 def test_bare_hex_oid_is_silently_skipped(
221 self, tmp_path: pathlib.Path
222 ) -> None:
223 """Bare 64-hex-char OID without sha256: prefix fails validate_object_id."""
224 repo = _make_repo(tmp_path)
225 _write_ref(repo, "bad", "a" * 64) # no sha256: prefix
226 data = json.loads(_sr(repo).output)
227 assert data["count"] == 0 # skipped silently
228
229 def test_valid_and_invalid_oid_mixed(self, tmp_path: pathlib.Path) -> None:
230 """Only the valid sha256:-prefixed ref is listed; bare hex is dropped."""
231 repo = _make_repo(tmp_path)
232 _write_ref(repo, "valid", _VALID_OID)
233 _write_ref(repo, "bare", "b" * 64) # intentionally bare hex — must be rejected
234 data = json.loads(_sr(repo).output)
235 assert data["count"] == 1
236 assert data["refs"][0]["ref"] == "refs/heads/valid"
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago