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