gabriel / muse public
test_cmd_show_ref_hardening.py python
266 lines 10.4 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 134 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 from collections.abc import Mapping
17
18 import json
19 import pathlib
20
21 import pytest
22
23 from muse.core._types import long_id
24 from tests.cli_test_helper import CliRunner, InvokeResult
25
26 runner = CliRunner()
27
28 _VALID_OID = long_id("a" * 64)
29
30
31 # ---------------------------------------------------------------------------
32 # Helpers
33 # ---------------------------------------------------------------------------
34
35
36 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
37 repo = tmp_path / "repo"
38 muse = repo / ".muse"
39 (muse / "objects").mkdir(parents=True)
40 (muse / "commits").mkdir(parents=True)
41 (muse / "snapshots").mkdir(parents=True)
42 (muse / "refs" / "heads").mkdir(parents=True)
43 (muse / "HEAD").write_text("ref: refs/heads/main")
44 (muse / "repo.json").write_text(json.dumps({"repo_id": "r1", "domain": "code"}))
45 return repo
46
47
48 def _write_ref(repo: pathlib.Path, branch: str, commit_id: str = _VALID_OID) -> None:
49 (repo / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
50
51
52 def _sr(repo: pathlib.Path, *args: str) -> InvokeResult:
53 from muse.cli.app import main as cli
54 return runner.invoke(cli, ["show-ref", *args],
55 env={"MUSE_REPO_ROOT": str(repo)})
56
57
58 def _assert_has_envelope(data: Mapping[str, object]) -> None:
59 assert "duration_ms" in data, f"'duration_ms' missing: {list(data)}"
60 assert "exit_code" in data, f"'exit_code' missing: {list(data)}"
61 assert isinstance(data["duration_ms"], float)
62 assert data["duration_ms"] >= 0.0
63 assert data["exit_code"] == 0
64
65
66 # ---------------------------------------------------------------------------
67 # TestElapsedAndExitCode — every JSON output path must carry the envelope
68 # ---------------------------------------------------------------------------
69
70
71 class TestElapsedAndExitCode:
72 def test_listing_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
73 repo = _make_repo(tmp_path)
74 data = json.loads(_sr(repo, "--json").output)
75 assert "duration_ms" in data
76
77 def test_listing_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
78 repo = _make_repo(tmp_path)
79 data = json.loads(_sr(repo, "--json").output)
80 assert isinstance(data["duration_ms"], float)
81 assert data["duration_ms"] >= 0.0
82
83 def test_listing_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
84 repo = _make_repo(tmp_path)
85 data = json.loads(_sr(repo, "--json").output)
86 assert data["exit_code"] == 0
87
88 def test_listing_with_refs_has_envelope(self, tmp_path: pathlib.Path) -> None:
89 repo = _make_repo(tmp_path)
90 _write_ref(repo, "main")
91 data = json.loads(_sr(repo, "--json").output)
92 _assert_has_envelope(data)
93
94 def test_count_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
95 repo = _make_repo(tmp_path)
96 data = json.loads(_sr(repo, "--count", "--json").output)
97 assert "duration_ms" in data
98
99 def test_count_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
100 repo = _make_repo(tmp_path)
101 data = json.loads(_sr(repo, "--count", "--json").output)
102 assert data["exit_code"] == 0
103
104 def test_count_json_has_count_field(self, tmp_path: pathlib.Path) -> None:
105 """Adding envelope must not drop the count field."""
106 repo = _make_repo(tmp_path)
107 _write_ref(repo, "main")
108 data = json.loads(_sr(repo, "--count", "--json").output)
109 assert data["count"] == 1
110
111 def test_head_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
112 repo = _make_repo(tmp_path)
113 _write_ref(repo, "main")
114 data = json.loads(_sr(repo, "--head", "--json").output)
115 assert "duration_ms" in data
116
117 def test_head_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
118 repo = _make_repo(tmp_path)
119 _write_ref(repo, "main")
120 data = json.loads(_sr(repo, "--head", "--json").output)
121 assert data["exit_code"] == 0
122
123 def test_head_null_json_has_envelope(self, tmp_path: pathlib.Path) -> None:
124 """Even when HEAD has no commit, the envelope must be present."""
125 repo = _make_repo(tmp_path)
126 data = json.loads(_sr(repo, "--head", "--json").output)
127 assert "duration_ms" in data
128 assert "exit_code" in data
129
130 def test_verify_exists_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
131 repo = _make_repo(tmp_path)
132 _write_ref(repo, "main")
133 data = json.loads(_sr(repo, "--verify", "refs/heads/main", "--json").output)
134 assert "duration_ms" in data
135
136 def test_verify_exists_json_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
137 repo = _make_repo(tmp_path)
138 _write_ref(repo, "main")
139 data = json.loads(_sr(repo, "--verify", "refs/heads/main", "--json").output)
140 assert data["exit_code"] == 0
141
142 def test_verify_not_exists_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
143 repo = _make_repo(tmp_path)
144 data = json.loads(_sr(repo, "--verify", "refs/heads/ghost", "--json").output)
145 assert "duration_ms" in data
146
147 def test_verify_not_exists_json_has_exit_code_nonzero(
148 self, tmp_path: pathlib.Path
149 ) -> None:
150 repo = _make_repo(tmp_path)
151 result = _sr(repo, "--verify", "refs/heads/ghost", "--json")
152 data = json.loads(result.output)
153 assert data["exit_code"] == result.exit_code
154 assert data["exit_code"] != 0
155
156
157 # ---------------------------------------------------------------------------
158 # TestErrorJson — format error must be plain text to stderr, not JSON to stderr
159 # ---------------------------------------------------------------------------
160
161
162 class TestErrorJson:
163 def test_bad_format_stdout_is_empty(self, tmp_path: pathlib.Path) -> None:
164 """Format error must not bleed anything to stdout."""
165 repo = _make_repo(tmp_path)
166 result = _sr(repo, "--format", "yaml")
167 assert result.exit_code != 0
168 assert result.stdout_bytes == b""
169
170 def test_bad_format_stderr_has_message(self, tmp_path: pathlib.Path) -> None:
171 """Format error message goes to stderr as plain text."""
172 repo = _make_repo(tmp_path)
173 result = _sr(repo, "--format", "yaml")
174 assert result.stderr # must not be empty
175 assert "\x1b[" not in result.stderr # no ANSI escapes
176
177 def test_bad_format_stderr_is_not_json(self, tmp_path: pathlib.Path) -> None:
178 """Format error should NOT be a JSON blob on stderr."""
179 repo = _make_repo(tmp_path)
180 result = _sr(repo, "--format", "yaml")
181 # Plain-text error: stderr should not parse as JSON
182 try:
183 json.loads(result.stderr)
184 is_json = True
185 except (json.JSONDecodeError, ValueError):
186 is_json = False
187 assert not is_json, f"stderr should be plain text, got JSON: {result.stderr!r}"
188
189
190 # ---------------------------------------------------------------------------
191 # TestRequiredKeysUpdated — listing JSON schema includes envelope fields
192 # ---------------------------------------------------------------------------
193
194
195 class TestRequiredKeysUpdated:
196 REQUIRED_KEYS = {"refs", "head", "count", "duration_ms", "exit_code"}
197
198 def test_listing_schema_complete(self, tmp_path: pathlib.Path) -> None:
199 repo = _make_repo(tmp_path)
200 data = json.loads(_sr(repo, "--json").output)
201 missing = self.REQUIRED_KEYS - set(data)
202 assert not missing, f"Missing JSON keys: {missing}"
203
204
205 # ---------------------------------------------------------------------------
206 # TestValidOidFormat — refs written with sha256: prefix are listed correctly
207 # ---------------------------------------------------------------------------
208
209
210 class TestValidOidFormat:
211 def test_sha256_prefixed_oid_appears_in_listing(
212 self, tmp_path: pathlib.Path
213 ) -> None:
214 """Only sha256:-prefixed OIDs are valid; bare hex is rejected."""
215 repo = _make_repo(tmp_path)
216 _write_ref(repo, "main", _VALID_OID)
217 data = json.loads(_sr(repo, "--json").output)
218 assert data["count"] == 1
219 assert data["refs"][0]["commit_id"] == _VALID_OID
220
221 def test_bare_hex_oid_is_silently_skipped(
222 self, tmp_path: pathlib.Path
223 ) -> None:
224 """Bare 64-hex-char OID without sha256: prefix fails validate_object_id."""
225 repo = _make_repo(tmp_path)
226 _write_ref(repo, "bad", "a" * 64) # no sha256: prefix
227 data = json.loads(_sr(repo, "--json").output)
228 assert data["count"] == 0 # skipped silently
229
230 def test_valid_and_invalid_oid_mixed(self, tmp_path: pathlib.Path) -> None:
231 """Only the valid sha256:-prefixed ref is listed; bare hex is dropped."""
232 repo = _make_repo(tmp_path)
233 _write_ref(repo, "valid", _VALID_OID)
234 _write_ref(repo, "bare", "b" * 64) # intentionally bare hex — must be rejected
235 data = json.loads(_sr(repo, "--json").output)
236 assert data["count"] == 1
237 assert data["refs"][0]["ref"] == "refs/heads/valid"
238
239
240 class TestRegisterFlags:
241 def test_default_json_out_is_false(self):
242 import argparse
243 from muse.cli.commands.show_ref import register
244 p = argparse.ArgumentParser()
245 subs = p.add_subparsers()
246 register(subs)
247 args = p.parse_args(["show-ref"])
248 assert args.json_out is False
249
250 def test_json_flag_sets_json_out(self):
251 import argparse
252 from muse.cli.commands.show_ref import register
253 p = argparse.ArgumentParser()
254 subs = p.add_subparsers()
255 register(subs)
256 args = p.parse_args(["show-ref", "--json"])
257 assert args.json_out is True
258
259 def test_j_shorthand_sets_json_out(self):
260 import argparse
261 from muse.cli.commands.show_ref import register
262 p = argparse.ArgumentParser()
263 subs = p.add_subparsers()
264 register(subs)
265 args = p.parse_args(["show-ref", "-j"])
266 assert args.json_out is True
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 134 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 140 days ago