gabriel / muse public
test_hub_body_file_assignee.py python
840 lines 32.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """8-tier tests for hub.py ergonomics: --body-file and --assignee.
2
3 Covers:
4 Tier 1 — Shape / Schema
5 Tier 2 — Round-Trip / Integration
6 Tier 3 — Edge Cases
7 Tier 4 — Stress
8 Tier 5 — Data Integrity
9 Tier 6 — Performance
10 Tier 7 — Security (extra vigilant — all inputs are user-supplied)
11 Tier 8 — Docstrings / API Contract
12 """
13
14 from __future__ import annotations
15
16 import json
17 import pathlib
18 import textwrap
19 import time
20 import unittest.mock
21
22 import pytest
23 from tests.cli_test_helper import CliRunner
24
25 from muse._version import __version__
26 from muse.cli.commands.hub import (
27 _MAX_HANDLE_LEN,
28 _HANDLE_RE,
29 _resolve_body,
30 _validate_assignee,
31 )
32 from muse.cli.config import set_hub_url
33 from muse.core.errors import ExitCode
34 from muse.core.identity import IdentityEntry, save_identity
35
36 cli = None
37 runner = CliRunner()
38
39
40 # ---------------------------------------------------------------------------
41 # Shared fixtures
42 # ---------------------------------------------------------------------------
43
44
45 @pytest.fixture()
46 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
47 """Minimal .muse/ repo with identity wired up."""
48 muse_dir = tmp_path / ".muse"
49 (muse_dir / "refs" / "heads").mkdir(parents=True)
50 (muse_dir / "objects").mkdir()
51 (muse_dir / "commits").mkdir()
52 (muse_dir / "snapshots").mkdir()
53 (muse_dir / "repo.json").write_text(
54 json.dumps({
55 "repo_id": "test-repo",
56 "schema_version": __version__,
57 "domain": "midi",
58 })
59 )
60 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
61 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
62 monkeypatch.chdir(tmp_path)
63
64 fake_id_dir = tmp_path / "fake_home" / ".muse"
65 fake_id_dir.mkdir(parents=True)
66 fake_id_file = fake_id_dir / "identity.toml"
67 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_id_dir)
68 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_id_file)
69 return tmp_path
70
71
72 def _setup_auth(repo: pathlib.Path, hub_url: str = "http://localhost:10003/gabriel/muse") -> None:
73 set_hub_url(hub_url, repo)
74 identity = IdentityEntry(
75 type="human",
76 handle="gabriel",
77 key_path=str(repo / "fake_home" / ".muse" / "keys" / "key.pem"),
78 algorithm="ed25519",
79 fingerprint="deadbeef",
80 )
81 save_identity(hub_url, identity)
82
83
84 def _hub_patches(calls: list[tuple], response: dict | None = None):
85 """Context manager that mocks hub network helpers and records calls."""
86 mock_resp = response or {"issueId": "abc-123", "number": 1, "title": "t"}
87
88 def _fake_api(hub_url, identity, method, path, **kw):
89 calls.append((method, path, kw))
90 return mock_resp
91
92 return unittest.mock.patch.multiple(
93 "muse.cli.commands.hub",
94 _hub_api=unittest.mock.MagicMock(side_effect=_fake_api),
95 _get_hub_and_identity=unittest.mock.MagicMock(
96 return_value=("http://localhost:10003/gabriel/muse", unittest.mock.MagicMock())
97 ),
98 _resolve_repo_id=unittest.mock.MagicMock(return_value="test-repo-id"),
99 )
100
101
102 # ===========================================================================
103 # Tier 1 — Shape / Schema
104 # ===========================================================================
105
106
107 class TestShape:
108 """Parser flags exist; helpers are importable with correct signatures."""
109
110 def test_resolve_body_importable(self) -> None:
111 """_resolve_body must be importable from hub."""
112 from muse.cli.commands.hub import _resolve_body
113 assert callable(_resolve_body)
114
115 def test_validate_assignee_importable(self) -> None:
116 """_validate_assignee must be importable from hub."""
117 from muse.cli.commands.hub import _validate_assignee
118 assert callable(_validate_assignee)
119
120 def test_handle_re_is_compiled_pattern(self) -> None:
121 """_HANDLE_RE must be a compiled regex."""
122 import re
123 assert isinstance(_HANDLE_RE, re.Pattern)
124
125 def test_max_handle_len_positive_int(self) -> None:
126 """_MAX_HANDLE_LEN must be a positive integer."""
127 assert isinstance(_MAX_HANDLE_LEN, int)
128 assert _MAX_HANDLE_LEN > 0
129
130 def test_assignee_flag_present_on_issue_create(self, repo: pathlib.Path) -> None:
131 """--assignee must appear in the issue create help text."""
132 _setup_auth(repo)
133 result = runner.invoke(cli, ["hub", "issue", "create", "--help"])
134 assert "--assignee" in result.output, (
135 f"--assignee flag missing from 'hub issue create --help': {result.output}"
136 )
137
138 def test_body_file_flag_present_on_issue_create(self, repo: pathlib.Path) -> None:
139 """--body-file must appear in the issue create help text."""
140 _setup_auth(repo)
141 result = runner.invoke(cli, ["hub", "issue", "create", "--help"])
142 assert "--body-file" in result.output
143
144 def test_body_file_flag_present_on_issue_update(self, repo: pathlib.Path) -> None:
145 """--body-file must appear in the issue update help text."""
146 _setup_auth(repo)
147 result = runner.invoke(cli, ["hub", "issue", "update", "--help"])
148 assert "--body-file" in result.output
149
150 def test_body_file_flag_present_on_issue_comment(self, repo: pathlib.Path) -> None:
151 """--body-file must appear in the issue comment help text."""
152 _setup_auth(repo)
153 result = runner.invoke(cli, ["hub", "issue", "comment", "--help"])
154 assert "--body-file" in result.output
155
156 def test_handle_re_matches_valid_handles(self) -> None:
157 """_HANDLE_RE must match well-formed handles."""
158 valid = [
159 "gabriel",
160 "aaronrene",
161 "mix-engine-7",
162 "studio_9",
163 "A",
164 "a1",
165 "z" * _MAX_HANDLE_LEN,
166 ]
167 for h in valid:
168 if len(h) <= _MAX_HANDLE_LEN:
169 assert _HANDLE_RE.match(h), f"_HANDLE_RE should match {h!r}"
170
171 def test_handle_re_rejects_leading_hyphen(self) -> None:
172 assert not _HANDLE_RE.match("-gabriel")
173
174 def test_handle_re_rejects_spaces(self) -> None:
175 assert not _HANDLE_RE.match("gab riel")
176
177 def test_handle_re_rejects_at_symbol(self) -> None:
178 assert not _HANDLE_RE.match("@gabriel")
179
180 def test_handle_re_rejects_slash(self) -> None:
181 assert not _HANDLE_RE.match("gabriel/muse")
182
183
184 # ===========================================================================
185 # Tier 2 — Round-Trip / Integration
186 # ===========================================================================
187
188
189 class TestRoundTrip:
190 """CLI invocations produce correct sequences of API calls."""
191
192 def test_issue_create_with_assignee_calls_create_then_assign(
193 self, repo: pathlib.Path
194 ) -> None:
195 """create + --assignee must POST to /issues then POST to /assign."""
196 _setup_auth(repo)
197 calls: list[tuple] = []
198 with _hub_patches(calls):
199 result = runner.invoke(
200 cli,
201 ["hub", "issue", "create", "--title", "Test", "--assignee", "aaronrene"],
202 )
203 assert result.exit_code == 0, result.output
204 methods_and_paths = [(m, p) for m, p, _ in calls]
205 assert any("/issues" in p and m == "POST" for m, p in methods_and_paths), (
206 f"Expected POST /issues; got {methods_and_paths}"
207 )
208 assert any("/assign" in p for _, p in methods_and_paths), (
209 f"Expected /assign call; got {methods_and_paths}"
210 )
211
212 def test_issue_create_without_assignee_does_not_call_assign(
213 self, repo: pathlib.Path
214 ) -> None:
215 """create without --assignee must NOT dispatch an /assign call."""
216 _setup_auth(repo)
217 calls: list[tuple] = []
218 with _hub_patches(calls):
219 result = runner.invoke(
220 cli, ["hub", "issue", "create", "--title", "No assignee"]
221 )
222 assert result.exit_code == 0, result.output
223 paths = [p for _, p, _ in calls]
224 assert not any("/assign" in p for p in paths), (
225 f"/assign was called even though no --assignee was given: {paths}"
226 )
227
228 def test_issue_create_with_body_file_sends_correct_body(
229 self, tmp_path: pathlib.Path, repo: pathlib.Path
230 ) -> None:
231 """Body read from --body-file must appear verbatim in the API payload."""
232 _setup_auth(repo)
233 body_text = "# My Issue\n\nHello `world`\n"
234 body_file = tmp_path / "body.md"
235 body_file.write_text(body_text, encoding="utf-8")
236
237 payloads: list[dict] = []
238
239 def _capturing_api(hub_url, identity, method, path, *, body=None, **kw):
240 if body:
241 payloads.append(body)
242 return {"issueId": "x", "number": 1, "title": "t"}
243
244 with unittest.mock.patch.multiple(
245 "muse.cli.commands.hub",
246 _hub_api=unittest.mock.MagicMock(side_effect=_capturing_api),
247 _get_hub_and_identity=unittest.mock.MagicMock(
248 return_value=("http://localhost:10003/gabriel/muse", unittest.mock.MagicMock())
249 ),
250 _resolve_repo_id=unittest.mock.MagicMock(return_value="test-repo-id"),
251 ):
252 result = runner.invoke(
253 cli,
254 ["hub", "issue", "create", "--title", "t", "--body-file", str(body_file)],
255 )
256 assert result.exit_code == 0, result.output
257 assert payloads, "No API payload captured"
258 assert payloads[0]["body"] == body_text
259
260 def test_issue_update_with_body_file_sends_correct_body(
261 self, tmp_path: pathlib.Path, repo: pathlib.Path
262 ) -> None:
263 """issue update --body-file must PATCH with the file contents."""
264 _setup_auth(repo)
265 body_text = "Updated body `with backticks`"
266 body_file = tmp_path / "update.md"
267 body_file.write_text(body_text, encoding="utf-8")
268
269 payloads: list[dict] = []
270
271 def _capturing_api(hub_url, identity, method, path, *, body=None, **kw):
272 if body:
273 payloads.append(body)
274 return {"number": 1, "title": "x", "state": "open"}
275
276 with unittest.mock.patch.multiple(
277 "muse.cli.commands.hub",
278 _hub_api=unittest.mock.MagicMock(side_effect=_capturing_api),
279 _get_hub_and_identity=unittest.mock.MagicMock(
280 return_value=("http://localhost:10003/gabriel/muse", unittest.mock.MagicMock())
281 ),
282 _resolve_repo_id=unittest.mock.MagicMock(return_value="test-repo-id"),
283 ):
284 result = runner.invoke(
285 cli,
286 ["hub", "issue", "update", "1", "--body-file", str(body_file)],
287 )
288 assert result.exit_code == 0, result.output
289 assert payloads and payloads[0]["body"] == body_text
290
291 def test_issue_assign_validates_handle_before_api_call(
292 self, repo: pathlib.Path
293 ) -> None:
294 """issue assign with a bad handle must fail before any network I/O."""
295 _setup_auth(repo)
296 calls: list[tuple] = []
297 with _hub_patches(calls):
298 result = runner.invoke(
299 cli,
300 ["hub", "issue", "assign", "1", "--assignee", "bad handle!"],
301 )
302 assert result.exit_code != 0
303 assert not calls, "API was called even though the handle was invalid"
304
305
306 # ===========================================================================
307 # Tier 3 — Edge Cases
308 # ===========================================================================
309
310
311 class TestEdgeCases:
312 """Boundary conditions and unusual-but-valid inputs."""
313
314 def test_body_file_wins_over_body_when_both_given(
315 self, tmp_path: pathlib.Path, repo: pathlib.Path
316 ) -> None:
317 """When both --body and --body-file are given, --body-file wins."""
318 _setup_auth(repo)
319 file_text = "from file"
320 body_file = tmp_path / "b.txt"
321 body_file.write_text(file_text, encoding="utf-8")
322
323 payloads: list[dict] = []
324
325 def _cap(hub_url, identity, method, path, *, body=None, **kw):
326 if body:
327 payloads.append(body)
328 return {"issueId": "x", "number": 1, "title": "t"}
329
330 with unittest.mock.patch.multiple(
331 "muse.cli.commands.hub",
332 _hub_api=unittest.mock.MagicMock(side_effect=_cap),
333 _get_hub_and_identity=unittest.mock.MagicMock(
334 return_value=("http://localhost:10003/gabriel/muse", unittest.mock.MagicMock())
335 ),
336 _resolve_repo_id=unittest.mock.MagicMock(return_value="test-repo-id"),
337 ):
338 result = runner.invoke(
339 cli,
340 ["hub", "issue", "create", "--title", "t",
341 "--body", "from inline",
342 "--body-file", str(body_file)],
343 )
344 assert result.exit_code == 0, result.output
345 assert payloads[0]["body"] == file_text, (
346 f"Expected file content, got {payloads[0]['body']!r}"
347 )
348
349 def test_body_file_missing_exits_with_user_error(
350 self, tmp_path: pathlib.Path, repo: pathlib.Path
351 ) -> None:
352 """--body-file pointing at a non-existent file must exit USER_ERROR."""
353 _setup_auth(repo)
354 result = runner.invoke(
355 cli,
356 ["hub", "issue", "create", "--title", "t",
357 "--body-file", str(tmp_path / "does_not_exist.md")],
358 )
359 assert result.exit_code == ExitCode.USER_ERROR
360
361 def test_validate_assignee_empty_allow_empty_true_ok(self) -> None:
362 """An empty handle is valid when allow_empty=True (unassign path)."""
363 _validate_assignee("", allow_empty=True) # must not raise
364
365 def test_validate_assignee_empty_allow_empty_false_raises(self) -> None:
366 """An empty handle is invalid when allow_empty=False (create path)."""
367 with pytest.raises(SystemExit) as exc_info:
368 _validate_assignee("", allow_empty=False)
369 assert exc_info.value.code == ExitCode.USER_ERROR
370
371 def test_validate_assignee_single_char_valid(self) -> None:
372 """A single alphanumeric char is a valid handle."""
373 _validate_assignee("a")
374
375 def test_issue_create_assignee_shown_in_success_output(
376 self, repo: pathlib.Path
377 ) -> None:
378 """After creation with --assignee, the assignee name should appear in output."""
379 _setup_auth(repo)
380 calls: list[tuple] = []
381 with _hub_patches(calls):
382 result = runner.invoke(
383 cli,
384 ["hub", "issue", "create", "--title", "t", "--assignee", "aaronrene"],
385 )
386 assert result.exit_code == 0, result.output
387 assert "aaronrene" in result.output
388
389 def test_resolve_body_neither_body_nor_body_file_returns_empty(self) -> None:
390 """When args has neither body nor body_file, _resolve_body returns ''."""
391 import argparse
392 args = argparse.Namespace(body=None, body_file=None)
393 assert _resolve_body(args) == ""
394
395 def test_resolve_body_body_only_returns_body(self) -> None:
396 import argparse
397 args = argparse.Namespace(body="hello", body_file=None)
398 assert _resolve_body(args) == "hello"
399
400 def test_resolve_body_body_file_none_returns_body_string(self) -> None:
401 import argparse
402 args = argparse.Namespace(body="hello", body_file=None)
403 assert _resolve_body(args) == "hello"
404
405 def test_resolve_body_body_file_path_returns_file_contents(
406 self, tmp_path: pathlib.Path
407 ) -> None:
408 import argparse
409 f = tmp_path / "b.txt"
410 f.write_text("file body", encoding="utf-8")
411 args = argparse.Namespace(body="", body_file=str(f))
412 assert _resolve_body(args) == "file body"
413
414 def test_resolve_body_stdin_sentinel(self, monkeypatch: pytest.MonkeyPatch) -> None:
415 """Passing '-' for body_file must read from stdin."""
416 import argparse
417 import io
418 monkeypatch.setattr("sys.stdin", io.StringIO("stdin body"))
419 args = argparse.Namespace(body="", body_file="-")
420 assert _resolve_body(args) == "stdin body"
421
422
423 # ===========================================================================
424 # Tier 4 — Stress
425 # ===========================================================================
426
427
428 class TestStress:
429 """Max-length inputs and bulk operations work without error."""
430
431 def test_max_length_valid_handle_accepted(self) -> None:
432 """A handle exactly _MAX_HANDLE_LEN chars long must be accepted."""
433 handle = "a" * _MAX_HANDLE_LEN
434 _validate_assignee(handle) # must not raise
435
436 def test_handle_one_over_max_rejected(self) -> None:
437 """A handle one char over _MAX_HANDLE_LEN must be rejected."""
438 handle = "a" * (_MAX_HANDLE_LEN + 1)
439 with pytest.raises(SystemExit) as exc_info:
440 _validate_assignee(handle)
441 assert exc_info.value.code == ExitCode.USER_ERROR
442
443 def test_large_body_file_read_correctly(self, tmp_path: pathlib.Path) -> None:
444 """A 200KB body file must be read in full without truncation."""
445 import argparse
446 large_body = "# heading\n\n" + ("line of content\n" * 12_000)
447 f = tmp_path / "large.md"
448 f.write_text(large_body, encoding="utf-8")
449 args = argparse.Namespace(body="", body_file=str(f))
450 result = _resolve_body(args)
451 assert result == large_body
452
453 def test_validate_assignee_called_1000_times_fast(self) -> None:
454 """_validate_assignee on a valid handle 1000× must complete quickly."""
455 start = time.monotonic()
456 for _ in range(1000):
457 _validate_assignee("gabriel")
458 elapsed = time.monotonic() - start
459 assert elapsed < 0.5, f"1000 validations took {elapsed:.3f}s — too slow"
460
461 def test_issue_create_with_many_labels_and_assignee(
462 self, repo: pathlib.Path
463 ) -> None:
464 """Multiple labels combined with --assignee must succeed."""
465 _setup_auth(repo)
466 calls: list[tuple] = []
467 with _hub_patches(calls):
468 result = runner.invoke(
469 cli,
470 [
471 "hub", "issue", "create",
472 "--title", "Big issue",
473 "--label", "bug",
474 "--label", "enhancement",
475 "--label", "phase-1",
476 "--assignee", "aaronrene",
477 ],
478 )
479 assert result.exit_code == 0, result.output
480 assert any("/assign" in p for _, p, _ in calls)
481
482
483 # ===========================================================================
484 # Tier 5 — Data Integrity
485 # ===========================================================================
486
487
488 class TestDataIntegrity:
489 """Payloads sent to the API match exactly what the user supplied."""
490
491 def test_assignee_payload_matches_flag_value(self, repo: pathlib.Path) -> None:
492 """The handle POSTed to /assign must be exactly what --assignee received."""
493 _setup_auth(repo)
494 captured_assign_body: list[dict] = []
495
496 def _cap(hub_url, identity, method, path, *, body=None, **kw):
497 if "/assign" in path and body:
498 captured_assign_body.append(body)
499 return {"issueId": "x", "number": 1, "title": "t"}
500
501 with unittest.mock.patch.multiple(
502 "muse.cli.commands.hub",
503 _hub_api=unittest.mock.MagicMock(side_effect=_cap),
504 _get_hub_and_identity=unittest.mock.MagicMock(
505 return_value=("http://localhost:10003/gabriel/muse", unittest.mock.MagicMock())
506 ),
507 _resolve_repo_id=unittest.mock.MagicMock(return_value="test-repo-id"),
508 ):
509 runner.invoke(
510 cli,
511 ["hub", "issue", "create", "--title", "t", "--assignee", "aaronrene"],
512 )
513 assert captured_assign_body, "No assign payload captured"
514 assert captured_assign_body[0]["assignee"] == "aaronrene"
515
516 def test_body_file_bytes_match_payload_exactly(
517 self, tmp_path: pathlib.Path, repo: pathlib.Path
518 ) -> None:
519 """Non-ASCII in body file (UTF-8 encoded) must round-trip unchanged."""
520 _setup_auth(repo)
521 body_text = "Header\n\n```python\nprint('hello')\n```\n\n— em-dash ✓"
522 body_file = tmp_path / "body.md"
523 body_file.write_text(body_text, encoding="utf-8")
524
525 payloads: list[dict] = []
526
527 def _cap(hub_url, identity, method, path, *, body=None, **kw):
528 if body:
529 payloads.append(body)
530 return {"issueId": "x", "number": 1, "title": "t"}
531
532 with unittest.mock.patch.multiple(
533 "muse.cli.commands.hub",
534 _hub_api=unittest.mock.MagicMock(side_effect=_cap),
535 _get_hub_and_identity=unittest.mock.MagicMock(
536 return_value=("http://localhost:10003/gabriel/muse", unittest.mock.MagicMock())
537 ),
538 _resolve_repo_id=unittest.mock.MagicMock(return_value="test-repo-id"),
539 ):
540 runner.invoke(
541 cli,
542 ["hub", "issue", "create", "--title", "t",
543 "--body-file", str(body_file)],
544 )
545 assert payloads and payloads[0]["body"] == body_text
546
547 def test_assign_called_with_correct_issue_number(
548 self, repo: pathlib.Path
549 ) -> None:
550 """The /assign path must contain the issue number returned by the create endpoint."""
551 _setup_auth(repo)
552 assign_paths: list[str] = []
553
554 def _cap(hub_url, identity, method, path, *, body=None, **kw):
555 if "/assign" in path:
556 assign_paths.append(path)
557 return {"issueId": "x", "number": 42, "title": "t"}
558
559 with unittest.mock.patch.multiple(
560 "muse.cli.commands.hub",
561 _hub_api=unittest.mock.MagicMock(side_effect=_cap),
562 _get_hub_and_identity=unittest.mock.MagicMock(
563 return_value=("http://localhost:10003/gabriel/muse", unittest.mock.MagicMock())
564 ),
565 _resolve_repo_id=unittest.mock.MagicMock(return_value="test-repo-id"),
566 ):
567 runner.invoke(
568 cli,
569 ["hub", "issue", "create", "--title", "t", "--assignee", "gabriel"],
570 )
571 assert assign_paths, "No /assign call found"
572 assert "42" in assign_paths[0], (
573 f"/assign path {assign_paths[0]!r} should contain issue number 42"
574 )
575
576
577 # ===========================================================================
578 # Tier 6 — Performance
579 # ===========================================================================
580
581
582 class TestPerformance:
583 """_resolve_body and _validate_assignee run in sub-millisecond time."""
584
585 def test_resolve_body_from_file_single_read(
586 self, tmp_path: pathlib.Path
587 ) -> None:
588 """_resolve_body must read the file exactly once (no double reads)."""
589 import argparse
590 f = tmp_path / "b.txt"
591 f.write_text("content", encoding="utf-8")
592
593 read_count = 0
594 real_open = open
595
596 def _counting_open(path, *a, **kw):
597 nonlocal read_count
598 if str(path) == str(f):
599 read_count += 1
600 return real_open(path, *a, **kw)
601
602 with unittest.mock.patch("builtins.open", side_effect=_counting_open):
603 args = argparse.Namespace(body="", body_file=str(f))
604 _resolve_body(args)
605
606 assert read_count == 1, f"File was read {read_count} times; expected exactly 1"
607
608 def test_validate_assignee_valid_under_1ms(self) -> None:
609 """Single _validate_assignee call on a valid handle must finish under 1 ms."""
610 start = time.monotonic()
611 _validate_assignee("gabriel")
612 elapsed = time.monotonic() - start
613 assert elapsed < 0.001, f"took {elapsed*1000:.2f} ms"
614
615 def test_validate_assignee_invalid_under_1ms(self) -> None:
616 """Rejection path must also complete under 1 ms."""
617 start = time.monotonic()
618 with pytest.raises(SystemExit):
619 _validate_assignee("bad handle!")
620 elapsed = time.monotonic() - start
621 assert elapsed < 0.001, f"took {elapsed*1000:.2f} ms"
622
623
624 # ===========================================================================
625 # Tier 7 — Security
626 # ===========================================================================
627
628
629 class TestSecurity:
630 """All user-supplied handle input is rigorously rejected for malformed values.
631
632 Threat model (see _validate_assignee docstring):
633 * Terminal injection via ANSI/control sequences
634 * Null-byte truncation attacks
635 * Newline injection — makes error messages look like success
636 * Unicode confusable characters impersonating ASCII handles
637 * Oversized input for DoS
638 * Shell metacharacters (defense-in-depth; args go into JSON anyway)
639 """
640
641 @pytest.mark.parametrize("bad_handle", [
642 "gab\x00riel", # null byte
643 "gab\nriel", # newline
644 "gab\rriel", # carriage return
645 "gab\triel", # tab
646 "\x01gabriel", # SOH control char
647 "gabriel\x1b[31mred", # ANSI escape — terminal injection
648 "gabriel\x7f", # DEL
649 "\x0cgabriel", # form feed
650 "gabriel\x0b", # vertical tab
651 "gabriel\x08extra", # backspace — visual overwrite attack
652 ])
653 def test_control_characters_rejected(self, bad_handle: str) -> None:
654 """Any handle with a control character must be rejected."""
655 with pytest.raises(SystemExit) as exc_info:
656 _validate_assignee(bad_handle)
657 assert exc_info.value.code == ExitCode.USER_ERROR, (
658 f"Expected USER_ERROR for handle {bad_handle!r}"
659 )
660
661 @pytest.mark.parametrize("bad_handle", [
662 "gаbriel", # Cyrillic 'а' (U+0430) — looks like 'a'
663 "aaronrenё", # Cyrillic 'ё' (U+0451)
664 "gábríel", # Latin accented chars
665 "gabriel™", # trademark symbol
666 "gabriel→muse", # arrow
667 "𝕘𝕒𝕓𝕣𝕚𝕖𝕝", # mathematical double-struck
668 ])
669 def test_non_ascii_unicode_rejected(self, bad_handle: str) -> None:
670 """Non-ASCII Unicode handles must be rejected (confusable / RTL risk)."""
671 with pytest.raises(SystemExit) as exc_info:
672 _validate_assignee(bad_handle)
673 assert exc_info.value.code == ExitCode.USER_ERROR, (
674 f"Expected USER_ERROR for handle {bad_handle!r}"
675 )
676
677 @pytest.mark.parametrize("bad_handle", [
678 "gabriel muse", # space
679 "gabriel@muse", # at sign
680 "gabriel/muse", # slash — path traversal appearance
681 "gabriel.muse", # dot
682 "gabriel!", # bang
683 "gabriel;rm -rf /", # shell injection attempt
684 "gabriel$(whoami)", # command substitution
685 "gabriel`id`", # backtick injection
686 "gabriel|cat /etc/passwd", # pipe
687 "gabriel&&echo pwned", # logical AND
688 "gabriel>>/etc/crontab", # redirection
689 ])
690 def test_shell_metacharacters_rejected(self, bad_handle: str) -> None:
691 """Shell metacharacters and non-alphanumeric symbols must be rejected."""
692 with pytest.raises(SystemExit) as exc_info:
693 _validate_assignee(bad_handle)
694 assert exc_info.value.code == ExitCode.USER_ERROR
695
696 def test_extremely_long_handle_rejected(self) -> None:
697 """A handle of 1000 chars must be rejected — DoS guard."""
698 with pytest.raises(SystemExit) as exc_info:
699 _validate_assignee("a" * 1000)
700 assert exc_info.value.code == ExitCode.USER_ERROR
701
702 def test_empty_handle_not_allowed_on_create(
703 self, repo: pathlib.Path
704 ) -> None:
705 """issue create --assignee '' must fail before any API call."""
706 _setup_auth(repo)
707 calls: list[tuple] = []
708 with _hub_patches(calls):
709 result = runner.invoke(
710 cli,
711 ["hub", "issue", "create", "--title", "t", "--assignee", ""],
712 )
713 assert result.exit_code != 0
714 assert not any("/issues" in p for _, p, _ in calls), (
715 "API was called even though the assignee was empty"
716 )
717
718 def test_invalid_handle_rejects_before_network_io(
719 self, repo: pathlib.Path
720 ) -> None:
721 """An invalid handle must fail before ANY network call is made."""
722 _setup_auth(repo)
723 calls: list[tuple] = []
724 with _hub_patches(calls):
725 result = runner.invoke(
726 cli,
727 ["hub", "issue", "create", "--title", "t",
728 "--assignee", "bad handle!"],
729 )
730 assert result.exit_code != 0
731 assert not calls, f"Network was called despite invalid handle: {calls}"
732
733 def test_body_file_path_not_leaked_on_error(
734 self, tmp_path: pathlib.Path, repo: pathlib.Path
735 ) -> None:
736 """Error message for missing --body-file must contain the path for
737 actionability, but must not expose sensitive filesystem layout beyond
738 what was explicitly given."""
739 _setup_auth(repo)
740 # Use a path that doesn't exist
741 missing = str(tmp_path / "secret_dir" / "body.md")
742 result = runner.invoke(
743 cli,
744 ["hub", "issue", "create", "--title", "t", "--body-file", missing],
745 )
746 assert result.exit_code == ExitCode.USER_ERROR
747 # The path given by the user may appear in the error (for debugging),
748 # but no other paths from the filesystem should be exposed.
749 assert "secret_dir" in result.output or "body.md" in result.output, (
750 "Error message should mention the problematic path for debuggability"
751 )
752
753 def test_issue_assign_invalid_handle_rejected(
754 self, repo: pathlib.Path
755 ) -> None:
756 """issue assign with control-char handle must fail before any API call."""
757 _setup_auth(repo)
758 calls: list[tuple] = []
759 with _hub_patches(calls):
760 result = runner.invoke(
761 cli,
762 ["hub", "issue", "assign", "1", "--assignee", "bad\x00handle"],
763 )
764 assert result.exit_code != 0
765 assert not calls
766
767 def test_issue_update_invalid_assign_rejected_before_network(
768 self, repo: pathlib.Path
769 ) -> None:
770 """issue update --assign with bad handle must fail before API call."""
771 _setup_auth(repo)
772 calls: list[tuple] = []
773 with _hub_patches(calls):
774 result = runner.invoke(
775 cli,
776 ["hub", "issue", "update", "1", "--assign", "bad handle!"],
777 )
778 assert result.exit_code != 0
779 assign_calls = [p for _, p, _ in calls if "/assign" in p]
780 assert not assign_calls
781
782
783 # ===========================================================================
784 # Tier 8 — Docstrings / API Contract
785 # ===========================================================================
786
787
788 class TestDocstrings:
789 """Key symbols have complete, accurate docstrings."""
790
791 def test_resolve_body_has_docstring(self) -> None:
792 """_resolve_body must have a non-empty docstring."""
793 assert _resolve_body.__doc__, "_resolve_body has no docstring"
794
795 def test_resolve_body_docstring_mentions_body_file(self) -> None:
796 assert "body_file" in _resolve_body.__doc__ or "body-file" in _resolve_body.__doc__
797
798 def test_resolve_body_docstring_mentions_stdin(self) -> None:
799 assert "-" in _resolve_body.__doc__, (
800 "Docstring should mention '-' as the stdin sentinel"
801 )
802
803 def test_resolve_body_docstring_has_args_section(self) -> None:
804 assert "Args:" in _resolve_body.__doc__
805
806 def test_resolve_body_docstring_has_returns_section(self) -> None:
807 assert "Returns:" in _resolve_body.__doc__
808
809 def test_resolve_body_docstring_has_raises_section(self) -> None:
810 assert "Raises:" in _resolve_body.__doc__
811
812 def test_validate_assignee_has_docstring(self) -> None:
813 assert _validate_assignee.__doc__, "_validate_assignee has no docstring"
814
815 def test_validate_assignee_docstring_mentions_allow_empty(self) -> None:
816 assert "allow_empty" in _validate_assignee.__doc__
817
818 def test_validate_assignee_docstring_has_threat_model(self) -> None:
819 doc = _validate_assignee.__doc__
820 assert any(
821 kw in doc
822 for kw in ("terminal", "injection", "null", "control", "Threat")
823 ), "Docstring should describe the threat model"
824
825 def test_validate_assignee_docstring_has_raises_section(self) -> None:
826 assert "Raises:" in _validate_assignee.__doc__
827
828 def test_validate_assignee_docstring_mentions_user_error(self) -> None:
829 assert "USER_ERROR" in _validate_assignee.__doc__
830
831 def test_handle_re_constant_is_named_consistently(self) -> None:
832 """_HANDLE_RE must follow the module constant naming convention."""
833 from muse.cli.commands import hub
834 assert hasattr(hub, "_HANDLE_RE"), "_HANDLE_RE not found in hub module"
835
836 def test_max_handle_len_constant_documented_in_code(self) -> None:
837 """_MAX_HANDLE_LEN must exist as a module-level constant."""
838 from muse.cli.commands import hub
839 assert hasattr(hub, "_MAX_HANDLE_LEN")
840 assert isinstance(hub._MAX_HANDLE_LEN, int)
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago