gabriel / muse public
test_errors_supercharge.py python
451 lines 21.0 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Seven-tier tests for ``muse/core/errors.py``.
2
3 Tiers
4 -----
5 Unit — ExitCode values/membership, exception construction, attributes.
6 Integration — Exceptions caught by broad handlers, exit-code propagation via SystemExit.
7 End-to-end — CLI commands surface correct exit codes for each error class.
8 Stress — 10 000 exception instantiations, concurrent raises.
9 Data integrity — Attribute correctness, message formatting, alias identity.
10 Security — Hostile strings in messages (ANSI, null bytes, path traversal).
11 Performance — Instantiation under 1 ms each.
12 """
13
14 from __future__ import annotations
15
16 import threading
17 import time
18
19 import pytest
20
21
22 # ──────────────────────────────────────────────────────────────────────────────
23 # Unit — ExitCode
24 # ──────────────────────────────────────────────────────────────────────────────
25
26
27 class TestExitCode:
28 def test_success_is_zero(self) -> None:
29 from muse.core.errors import ExitCode
30 assert ExitCode.SUCCESS == 0
31
32 def test_user_error_is_one(self) -> None:
33 from muse.core.errors import ExitCode
34 assert ExitCode.USER_ERROR == 1
35
36 def test_repo_not_found_is_two(self) -> None:
37 from muse.core.errors import ExitCode
38 assert ExitCode.REPO_NOT_FOUND == 2
39
40 def test_internal_error_is_three(self) -> None:
41 from muse.core.errors import ExitCode
42 assert ExitCode.INTERNAL_ERROR == 3
43
44 def test_not_found_is_four(self) -> None:
45 from muse.core.errors import ExitCode
46 assert ExitCode.NOT_FOUND == 4
47
48 def test_remote_error_is_five(self) -> None:
49 from muse.core.errors import ExitCode
50 assert ExitCode.REMOTE_ERROR == 5
51
52 def test_is_int_enum(self) -> None:
53 import enum
54 from muse.core.errors import ExitCode
55 assert issubclass(ExitCode, enum.IntEnum)
56
57 def test_all_six_members_present(self) -> None:
58 from muse.core.errors import ExitCode
59 assert len(ExitCode) == 6
60
61 def test_comparable_to_int(self) -> None:
62 from muse.core.errors import ExitCode
63 assert ExitCode.USER_ERROR == 1
64 assert ExitCode.SUCCESS < ExitCode.USER_ERROR
65
66 def test_usable_as_system_exit_code(self) -> None:
67 from muse.core.errors import ExitCode
68 with pytest.raises(SystemExit) as exc:
69 raise SystemExit(ExitCode.USER_ERROR)
70 assert exc.value.code == 1
71
72
73 # ──────────────────────────────────────────────────────────────────────────────
74 # Unit — MuseCLIError
75 # ──────────────────────────────────────────────────────────────────────────────
76
77
78 class TestMuseCLIError:
79 def test_is_exception(self) -> None:
80 from muse.core.errors import MuseCLIError
81 assert issubclass(MuseCLIError, Exception)
82
83 def test_message_stored(self) -> None:
84 from muse.core.errors import MuseCLIError
85 e = MuseCLIError("oops")
86 assert str(e) == "oops"
87
88 def test_default_exit_code_is_internal_error(self) -> None:
89 from muse.core.errors import ExitCode, MuseCLIError
90 e = MuseCLIError("oops")
91 assert e.exit_code == ExitCode.INTERNAL_ERROR
92
93 def test_custom_exit_code(self) -> None:
94 from muse.core.errors import ExitCode, MuseCLIError
95 e = MuseCLIError("bad input", ExitCode.USER_ERROR)
96 assert e.exit_code == ExitCode.USER_ERROR
97
98 def test_catchable_as_exception(self) -> None:
99 from muse.core.errors import MuseCLIError
100 with pytest.raises(Exception):
101 raise MuseCLIError("test")
102
103
104 # ──────────────────────────────────────────────────────────────────────────────
105 # Unit — RepoNotFoundError
106 # ──────────────────────────────────────────────────────────────────────────────
107
108
109 class TestRepoNotFoundError:
110 def test_is_muse_cli_error(self) -> None:
111 from muse.core.errors import MuseCLIError, RepoNotFoundError
112 assert issubclass(RepoNotFoundError, MuseCLIError)
113
114 def test_default_message_mentions_muse_init(self) -> None:
115 from muse.core.errors import RepoNotFoundError
116 e = RepoNotFoundError()
117 assert "muse init" in str(e).lower() or "init" in str(e)
118
119 def test_exit_code_is_repo_not_found(self) -> None:
120 from muse.core.errors import ExitCode, RepoNotFoundError
121 e = RepoNotFoundError()
122 assert e.exit_code == ExitCode.REPO_NOT_FOUND
123
124 def test_custom_message(self) -> None:
125 from muse.core.errors import RepoNotFoundError
126 e = RepoNotFoundError("custom msg")
127 assert "custom msg" in str(e)
128
129 def test_catchable_as_muse_cli_error(self) -> None:
130 from muse.core.errors import MuseCLIError, RepoNotFoundError
131 with pytest.raises(MuseCLIError):
132 raise RepoNotFoundError()
133
134
135 # ──────────────────────────────────────────────────────────────────────────────
136 # Unit — MuseNotARepoError alias
137 # ──────────────────────────────────────────────────────────────────────────────
138
139
140 class TestMuseNotARepoError:
141 def test_is_same_class_as_repo_not_found(self) -> None:
142 from muse.core.errors import MuseNotARepoError, RepoNotFoundError
143 assert MuseNotARepoError is RepoNotFoundError
144
145 def test_alias_raises_same_exception(self) -> None:
146 from muse.core.errors import MuseNotARepoError, RepoNotFoundError
147 with pytest.raises(RepoNotFoundError):
148 raise MuseNotARepoError()
149
150
151 # ──────────────────────────────────────────────────────────────────────────────
152 # Unit — UntrustedRepositoryError
153 # ──────────────────────────────────────────────────────────────────────────────
154
155
156 class TestUntrustedRepositoryError:
157 def test_is_permission_error(self) -> None:
158 from muse.core.errors import UntrustedRepositoryError
159 assert issubclass(UntrustedRepositoryError, PermissionError)
160
161 def test_stores_repo_path(self) -> None:
162 from muse.core.errors import UntrustedRepositoryError
163 e = UntrustedRepositoryError("/tmp/repo", owner_uid=1000, current_uid=1001)
164 assert e.repo_path == "/tmp/repo"
165
166 def test_stores_owner_uid(self) -> None:
167 from muse.core.errors import UntrustedRepositoryError
168 e = UntrustedRepositoryError("/tmp/repo", owner_uid=1000, current_uid=1001)
169 assert e.owner_uid == 1000
170
171 def test_stores_current_uid(self) -> None:
172 from muse.core.errors import UntrustedRepositoryError
173 e = UntrustedRepositoryError("/tmp/repo", owner_uid=1000, current_uid=1001)
174 assert e.current_uid == 1001
175
176 def test_message_mentions_path(self) -> None:
177 from muse.core.errors import UntrustedRepositoryError
178 e = UntrustedRepositoryError("/tmp/repo", owner_uid=1000, current_uid=1001)
179 assert "/tmp/repo" in str(e)
180
181 def test_message_mentions_both_uids(self) -> None:
182 from muse.core.errors import UntrustedRepositoryError
183 e = UntrustedRepositoryError("/tmp/repo", owner_uid=1000, current_uid=1001)
184 msg = str(e)
185 assert "1000" in msg
186 assert "1001" in msg
187
188 def test_message_mentions_trust_command(self) -> None:
189 from muse.core.errors import UntrustedRepositoryError
190 e = UntrustedRepositoryError("/tmp/repo", owner_uid=1000, current_uid=1001)
191 assert "muse trust" in str(e)
192
193 def test_catchable_as_permission_error(self) -> None:
194 from muse.core.errors import UntrustedRepositoryError
195 with pytest.raises(PermissionError):
196 raise UntrustedRepositoryError("/tmp/repo", 1000, 1001)
197
198
199 # ──────────────────────────────────────────────────────────────────────────────
200 # Unit — HubFingerprintMismatchError
201 # ──────────────────────────────────────────────────────────────────────────────
202
203
204 class TestHubFingerprintMismatchError:
205 def test_is_exception(self) -> None:
206 from muse.core.errors import HubFingerprintMismatchError
207 assert issubclass(HubFingerprintMismatchError, Exception)
208
209 def test_stores_hostname(self) -> None:
210 from muse.core.errors import HubFingerprintMismatchError
211 e = HubFingerprintMismatchError("hub.example.com", "aaa", "bbb")
212 assert e.hostname == "hub.example.com"
213
214 def test_stores_stored_fingerprint(self) -> None:
215 from muse.core.errors import HubFingerprintMismatchError
216 e = HubFingerprintMismatchError("hub.example.com", "aaa", "bbb")
217 assert e.stored_fingerprint == "aaa"
218
219 def test_stores_actual_fingerprint(self) -> None:
220 from muse.core.errors import HubFingerprintMismatchError
221 e = HubFingerprintMismatchError("hub.example.com", "aaa", "bbb")
222 assert e.actual_fingerprint == "bbb"
223
224 def test_message_mentions_hostname(self) -> None:
225 from muse.core.errors import HubFingerprintMismatchError
226 e = HubFingerprintMismatchError("hub.example.com", "aaa", "bbb")
227 assert "hub.example.com" in str(e)
228
229 def test_message_mentions_both_fingerprints(self) -> None:
230 from muse.core.errors import HubFingerprintMismatchError
231 e = HubFingerprintMismatchError("hub.example.com", "stored-fp", "actual-fp")
232 msg = str(e)
233 assert "stored-fp" in msg
234 assert "actual-fp" in msg
235
236 def test_message_mentions_mitm_risk(self) -> None:
237 from muse.core.errors import HubFingerprintMismatchError
238 e = HubFingerprintMismatchError("hub.example.com", "aaa", "bbb")
239 msg = str(e).lower()
240 assert "man-in-the-middle" in msg or "mitm" in msg or "mismatch" in msg
241
242 def test_message_mentions_hub_reset(self) -> None:
243 from muse.core.errors import HubFingerprintMismatchError
244 e = HubFingerprintMismatchError("hub.example.com", "aaa", "bbb")
245 assert "hub-reset" in str(e)
246
247
248 # ──────────────────────────────────────────────────────────────────────────────
249 # Integration — exception hierarchy and handler compatibility
250 # ──────────────────────────────────────────────────────────────────────────────
251
252
253 class TestIntegration:
254 def test_repo_not_found_not_caught_by_os_error(self) -> None:
255 """MuseCLIError inherits Exception, not OSError — OSError does not catch it."""
256 from muse.core.errors import RepoNotFoundError
257 with pytest.raises(RepoNotFoundError):
258 try:
259 raise RepoNotFoundError()
260 except OSError:
261 pytest.fail("RepoNotFoundError should not be caught by OSError")
262
263 def test_untrusted_repo_caught_by_os_error(self) -> None:
264 from muse.core.errors import UntrustedRepositoryError
265 with pytest.raises(OSError):
266 raise UntrustedRepositoryError("/p", 0, 1)
267
268 def test_exit_code_propagates_through_system_exit(self) -> None:
269 from muse.core.errors import ExitCode, MuseCLIError
270 e = MuseCLIError("fail", ExitCode.NOT_FOUND)
271 with pytest.raises(SystemExit) as exc:
272 raise SystemExit(e.exit_code)
273 assert exc.value.code == 4
274
275 def test_all_exit_codes_valid_process_exit_codes(self) -> None:
276 from muse.core.errors import ExitCode
277 for code in ExitCode:
278 assert 0 <= int(code) <= 127
279
280 def test_repo_not_found_exit_code_matches_enum(self) -> None:
281 from muse.core.errors import ExitCode, RepoNotFoundError
282 e = RepoNotFoundError()
283 assert int(e.exit_code) == int(ExitCode.REPO_NOT_FOUND)
284
285
286 # ──────────────────────────────────────────────────────────────────────────────
287 # End-to-end — CLI surfaces correct exit codes
288 # ──────────────────────────────────────────────────────────────────────────────
289
290
291 class TestEndToEnd:
292 def test_muse_outside_repo_exits_nonzero(self, tmp_path) -> None:
293 import os
294 from tests.cli_test_helper import CliRunner
295 r = CliRunner()
296 saved = os.getcwd()
297 try:
298 os.chdir(tmp_path)
299 result = r.invoke(None, ["status"])
300 finally:
301 os.chdir(saved)
302 assert result.exit_code != 0
303
304 def test_muse_outside_repo_exit_code_is_repo_not_found(self, tmp_path) -> None:
305 import os
306 from muse.core.errors import ExitCode
307 from tests.cli_test_helper import CliRunner
308 r = CliRunner()
309 saved = os.getcwd()
310 try:
311 os.chdir(tmp_path)
312 result = r.invoke(None, ["status"])
313 finally:
314 os.chdir(saved)
315 assert result.exit_code == ExitCode.REPO_NOT_FOUND
316
317
318 # ──────────────────────────────────────────────────────────────────────────────
319 # Stress
320 # ──────────────────────────────────────────────────────────────────────────────
321
322
323 class TestStress:
324 def test_10000_muse_cli_error_instantiations(self) -> None:
325 from muse.core.errors import ExitCode, MuseCLIError
326 for i in range(10_000):
327 e = MuseCLIError(f"error {i}", ExitCode.USER_ERROR)
328 assert e.exit_code == ExitCode.USER_ERROR
329
330 def test_concurrent_exception_raises_all_succeed(self) -> None:
331 from muse.core.errors import RepoNotFoundError
332 results: list[bool] = []
333 lock = threading.Lock()
334
335 def _raise() -> None:
336 try:
337 raise RepoNotFoundError()
338 except RepoNotFoundError:
339 with lock:
340 results.append(True)
341
342 threads = [threading.Thread(target=_raise) for _ in range(50)]
343 for t in threads:
344 t.start()
345 for t in threads:
346 t.join()
347 assert len(results) == 50
348
349 def test_10000_untrusted_repo_error_instantiations(self) -> None:
350 from muse.core.errors import UntrustedRepositoryError
351 for i in range(10_000):
352 e = UntrustedRepositoryError(f"/repo/{i}", owner_uid=i, current_uid=i + 1)
353 assert e.owner_uid == i
354
355
356 # ──────────────────────────────────────────────────────────────────────────────
357 # Data integrity
358 # ──────────────────────────────────────────────────────────────────────────────
359
360
361 class TestDataIntegrity:
362 def test_exit_code_values_are_unique(self) -> None:
363 from muse.core.errors import ExitCode
364 values = [int(c) for c in ExitCode]
365 assert len(values) == len(set(values))
366
367 def test_muse_cli_error_exit_code_attribute_is_exit_code_instance(self) -> None:
368 from muse.core.errors import ExitCode, MuseCLIError
369 e = MuseCLIError("x", ExitCode.REMOTE_ERROR)
370 assert isinstance(e.exit_code, ExitCode)
371
372 def test_untrusted_repo_attributes_independent_of_message(self) -> None:
373 from muse.core.errors import UntrustedRepositoryError
374 e = UntrustedRepositoryError("/path", owner_uid=42, current_uid=99)
375 assert e.repo_path == "/path"
376 assert e.owner_uid == 42
377 assert e.current_uid == 99
378
379 def test_fingerprint_mismatch_attributes_independent_of_message(self) -> None:
380 from muse.core.errors import HubFingerprintMismatchError
381 e = HubFingerprintMismatchError("host", "s1", "a1")
382 assert e.hostname == "host"
383 assert e.stored_fingerprint == "s1"
384 assert e.actual_fingerprint == "a1"
385
386 def test_repo_not_found_is_exact_alias(self) -> None:
387 from muse.core.errors import MuseNotARepoError, RepoNotFoundError
388 assert MuseNotARepoError is RepoNotFoundError
389 assert id(MuseNotARepoError) == id(RepoNotFoundError)
390
391
392 # ──────────────────────────────────────────────────────────────────────────────
393 # Security
394 # ──────────────────────────────────────────────────────────────────────────────
395
396
397 class TestSecurity:
398 def test_ansi_in_untrusted_path_preserved_in_attribute(self) -> None:
399 """The path attribute stores raw input — callers must sanitize for display."""
400 from muse.core.errors import UntrustedRepositoryError
401 evil = "/tmp/\x1b[31mevil\x1b[0m"
402 e = UntrustedRepositoryError(evil, owner_uid=0, current_uid=1)
403 assert e.repo_path == evil # stored as-is
404
405 def test_null_byte_in_muse_cli_error_message_does_not_crash(self) -> None:
406 from muse.core.errors import MuseCLIError
407 e = MuseCLIError("msg\x00with\x00nulls")
408 assert "\x00" in str(e) # stored, not stripped
409
410 def test_very_long_message_does_not_crash(self) -> None:
411 from muse.core.errors import MuseCLIError
412 long_msg = "x" * 100_000
413 e = MuseCLIError(long_msg)
414 assert len(str(e)) == 100_000
415
416 def test_fingerprint_mismatch_with_hostile_fingerprint_strings(self) -> None:
417 from muse.core.errors import HubFingerprintMismatchError
418 evil_fp = "'; DROP TABLE fingerprints; --"
419 e = HubFingerprintMismatchError("host", evil_fp, "actual")
420 assert e.stored_fingerprint == evil_fp
421
422
423 # ──────────────────────────────────────────────────────────────────────────────
424 # Performance
425 # ──────────────────────────────────────────────────────────────────────────────
426
427
428 class TestPerformance:
429 def test_exit_code_lookup_under_1ms(self) -> None:
430 from muse.core.errors import ExitCode
431 start = time.perf_counter()
432 for _ in range(1000):
433 _ = ExitCode.USER_ERROR
434 elapsed = time.perf_counter() - start
435 assert elapsed < 0.1 # 1000 lookups in < 100 ms
436
437 def test_muse_cli_error_instantiation_under_1ms_each(self) -> None:
438 from muse.core.errors import ExitCode, MuseCLIError
439 start = time.perf_counter()
440 for i in range(1000):
441 MuseCLIError(f"msg {i}", ExitCode.USER_ERROR)
442 elapsed = time.perf_counter() - start
443 assert elapsed < 1.0 # 1000 instances in < 1s (i.e. < 1ms each)
444
445 def test_untrusted_repo_error_instantiation_fast(self) -> None:
446 from muse.core.errors import UntrustedRepositoryError
447 start = time.perf_counter()
448 for i in range(1000):
449 UntrustedRepositoryError(f"/repo/{i}", i, i + 1)
450 elapsed = time.perf_counter() - start
451 assert elapsed < 1.0
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago