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