gabriel / muse public
test_cmd_release_hardening.py python
2,128 lines 87.4 KB
Raw
1 """Hardening tests for ``muse release`` and ``muse/cli/commands/release.py``.
2
3 Covers:
4 - Security: ANSI-safe body rendering via sanitize_display
5 - Security: TTY guard on ``muse release delete`` without --yes
6 - Error routing: all user-visible errors go to stderr
7 - JSON schema: add, list, show, push dry-run, delete dry-run, delete aborted
8 - --dry-run push: no network call, structured output
9 - --dry-run delete: no deletion, structured output
10 - --json flag: push, delete, show, list, add
11 - --commit alias for --ref on add
12 - Integration: full lifecycle add → show → list → delete
13 - Integration: channel filtering
14 - Stress: 50 releases, concurrent list reads
15 """
16
17 from __future__ import annotations
18
19 import datetime
20 import json
21 import pathlib
22 import threading
23 from typing import TypedDict
24 from unittest.mock import MagicMock, patch
25
26 import pytest
27
28 from tests.cli_test_helper import CliRunner, InvokeResult
29 from muse.core.types import Manifest, fake_id
30 from muse.core.semver import SemVerTag
31 from muse.core.releases import (
32 ReleaseRecord,
33 delete_release,
34 get_release_for_tag,
35 list_releases,
36 write_release,
37 )
38 from muse.core.paths import muse_dir, ref_path
39
40 runner = CliRunner()
41
42
43 # ---------------------------------------------------------------------------
44 # TypedDicts for JSON schema validation
45 # ---------------------------------------------------------------------------
46
47
48 class _PushJson(TypedDict):
49 status: str
50 tag: str
51 remote: str
52 release_id: str
53 dry_run: bool
54
55
56 class _DeleteJson(TypedDict):
57 status: str
58 tag: str
59 was_draft: bool
60 remote_retracted: bool
61 dry_run: bool
62
63
64 class _ShowJson(TypedDict):
65 tag: str
66 channel: str
67 commit_id: str
68 snapshot_id: str
69 release_id: str
70 is_draft: bool
71
72
73 # ---------------------------------------------------------------------------
74 # Helpers
75 # ---------------------------------------------------------------------------
76
77
78 def _env(root: pathlib.Path) -> Manifest:
79 return {"MUSE_REPO_ROOT": str(root)}
80
81
82 def _init_repo(tmp_path: pathlib.Path, domain: str = "code") -> tuple[pathlib.Path, str]:
83 dot_muse = muse_dir(tmp_path)
84 dot_muse.mkdir()
85 repo_id = fake_id("repo")
86 (dot_muse / "repo.json").write_text(
87 json.dumps({"repo_id": repo_id, "domain": domain, "default_branch": "main"}),
88 encoding="utf-8",
89 )
90 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
91 (dot_muse / "refs" / "heads").mkdir(parents=True)
92 (dot_muse / "snapshots").mkdir()
93 (dot_muse / "commits").mkdir()
94 (dot_muse / "objects").mkdir()
95 return tmp_path, repo_id
96
97
98 def _make_commit(
99 root: pathlib.Path,
100 repo_id: str,
101 branch: str = "main",
102 message: str = "feat: add",
103 sem_ver_bump: str = "minor",
104 ) -> str:
105 from muse.core.ids import hash_snapshot, hash_commit
106 from muse.core.commits import (
107 CommitRecord,
108 write_commit,
109 )
110 from muse.core.snapshots import (
111 SnapshotRecord,
112 write_snapshot,
113 )
114 from muse.domain import SemVerBump
115
116 ref_file = ref_path(root, branch)
117 raw_parent = ref_file.read_text().strip() if ref_file.exists() else ""
118 parent_id: str | None = raw_parent if raw_parent else None
119 manifest: Manifest = {}
120 snap_id = hash_snapshot(manifest)
121 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
122 now = datetime.datetime.now(datetime.timezone.utc)
123 parent_ids: list[str] = [parent_id] if parent_id else []
124 commit_id = hash_commit(
125 parent_ids=parent_ids,
126 snapshot_id=snap_id,
127 message=message,
128 committed_at_iso=now.isoformat(),
129 )
130 _bump_map = {
131 "major": "major", "minor": "minor", "patch": "patch", "none": "none"
132 }
133 bump_val: SemVerBump = _bump_map.get(sem_ver_bump, "none")
134 write_commit(root, CommitRecord(
135 commit_id=commit_id,
136 branch=branch,
137 snapshot_id=snap_id,
138 message=message,
139 committed_at=now,
140 parent_commit_id=parent_id,
141 sem_ver_bump=bump_val,
142 ))
143 ref_file.write_text(commit_id, encoding="utf-8")
144 return commit_id
145
146
147 def _write_release(root: pathlib.Path, repo_id: str, tag: str, is_draft: bool = False) -> ReleaseRecord:
148 from muse.core.semver import ReleaseChannel
149 sv_raw = tag.lstrip("v").split("-")
150 parts = sv_raw[0].split(".")
151 major, minor, patch_num = int(parts[0]), int(parts[1]), int(parts[2])
152 pre = sv_raw[1] if len(sv_raw) > 1 else ""
153 semver = SemVerTag(major=major, minor=minor, patch=patch_num, pre=pre, build="")
154 _channel_map = {
155 "beta": "beta", "alpha": "alpha", "nightly": "nightly",
156 }
157 channel: ReleaseChannel = _channel_map.get(
158 next((k for k in _channel_map if k in pre), ""), "stable"
159 )
160 rec = ReleaseRecord(
161 repo_id=repo_id,
162 release_id=fake_id(f"release-{tag}"),
163 tag=tag,
164 semver=semver,
165 channel=channel,
166 commit_id="a" * 64,
167 snapshot_id="b" * 64,
168 title=f"Release {tag}",
169 body="",
170 changelog=[],
171 is_draft=is_draft,
172 )
173 write_release(root, rec)
174 return rec
175
176
177 def _invoke(args: list[str], repo: pathlib.Path) -> InvokeResult:
178 return runner.invoke(None, args, env=_env(repo))
179
180
181 def _json_blob(output: str) -> str:
182 for line in output.splitlines():
183 line = line.strip()
184 if line.startswith("{") or line.startswith("["):
185 return line
186 return output.strip()
187
188
189 def _parse_push(output: str) -> _PushJson:
190 raw = json.loads(_json_blob(output))
191 assert isinstance(raw, dict)
192 status = raw["status"]
193 tag = raw["tag"]
194 remote = raw["remote"]
195 release_id = raw["release_id"]
196 dry_run = raw["dry_run"]
197 assert isinstance(status, str)
198 assert isinstance(tag, str)
199 assert isinstance(remote, str)
200 assert isinstance(release_id, str)
201 assert isinstance(dry_run, bool)
202 return _PushJson(status=status, tag=tag, remote=remote, release_id=release_id, dry_run=dry_run)
203
204
205 def _parse_delete(output: str) -> _DeleteJson:
206 raw = json.loads(_json_blob(output))
207 assert isinstance(raw, dict)
208 status = raw["status"]
209 tag = raw["tag"]
210 was_draft = raw["was_draft"]
211 remote_retracted = raw["remote_retracted"]
212 dry_run = raw["dry_run"]
213 assert isinstance(status, str)
214 assert isinstance(tag, str)
215 assert isinstance(was_draft, bool)
216 assert isinstance(remote_retracted, bool)
217 assert isinstance(dry_run, bool)
218 return _DeleteJson(
219 status=status,
220 tag=tag,
221 was_draft=was_draft,
222 remote_retracted=remote_retracted,
223 dry_run=dry_run,
224 )
225
226
227 def _parse_show(output: str) -> _ShowJson:
228 raw = json.loads(_json_blob(output))
229 assert isinstance(raw, dict)
230 tag = raw["tag"]
231 channel = raw["channel"]
232 commit_id = raw["commit_id"]
233 snapshot_id = raw["snapshot_id"]
234 release_id = raw["release_id"]
235 is_draft = raw["is_draft"]
236 assert isinstance(tag, str)
237 assert isinstance(channel, str)
238 assert isinstance(commit_id, str)
239 assert isinstance(snapshot_id, str)
240 assert isinstance(release_id, str)
241 assert isinstance(is_draft, bool)
242 return _ShowJson(
243 tag=tag, channel=channel, commit_id=commit_id,
244 snapshot_id=snapshot_id, release_id=release_id, is_draft=is_draft,
245 )
246
247
248 # ---------------------------------------------------------------------------
249 # Security: ANSI injection in body text
250 # ---------------------------------------------------------------------------
251
252
253 def test_body_ansi_stripped_in_text_output(tmp_path: pathlib.Path) -> None:
254 """ANSI codes in release.body must be stripped before terminal output."""
255 root, repo_id = _init_repo(tmp_path)
256 _make_commit(root, repo_id)
257 ansi_body = "\x1b[31mDanger\x1b[0m"
258 result = _invoke(
259 ["release", "add", "v1.0.0", "--body", ansi_body], root
260 )
261 assert result.exit_code == 0
262
263 show = _invoke(["release", "read", "v1.0.0"], root)
264 assert result.exit_code == 0
265 # ANSI escape sequences must not appear in the text output.
266 assert "\x1b[" not in show.output
267
268
269 def test_title_ansi_stripped_in_text_output(tmp_path: pathlib.Path) -> None:
270 root, repo_id = _init_repo(tmp_path)
271 _make_commit(root, repo_id)
272 ansi_title = "\x1b[1mBold\x1b[0m Release"
273 result = _invoke(["release", "add", "v1.0.0", "--title", ansi_title], root)
274 assert result.exit_code == 0
275 show = _invoke(["release", "read", "v1.0.0"], root)
276 assert "\x1b[" not in show.output
277
278
279 # ---------------------------------------------------------------------------
280 # Security: TTY guard on delete without --yes
281 # ---------------------------------------------------------------------------
282
283
284 def test_delete_published_non_tty_without_yes_fails(tmp_path: pathlib.Path) -> None:
285 """Non-TTY delete without --yes must exit USER_ERROR, never block."""
286 root, repo_id = _init_repo(tmp_path)
287 _write_release(root, repo_id, "v1.0.0", is_draft=False)
288 result = _invoke(["release", "delete", "v1.0.0"], root)
289 assert result.exit_code != 0
290 assert "TTY" in result.stderr or "--yes" in result.stderr
291
292
293 def test_delete_draft_non_tty_without_yes_fails(tmp_path: pathlib.Path) -> None:
294 """Even draft deletes require --yes in non-TTY contexts."""
295 root, repo_id = _init_repo(tmp_path)
296 _write_release(root, repo_id, "v1.0.0-alpha.1", is_draft=True)
297 result = _invoke(["release", "delete", "v1.0.0-alpha.1"], root)
298 assert result.exit_code != 0
299 assert "TTY" in result.stderr or "--yes" in result.stderr
300
301
302 # ---------------------------------------------------------------------------
303 # Error routing: errors go to stderr
304 # ---------------------------------------------------------------------------
305
306
307 def test_add_invalid_semver_error_to_stderr(tmp_path: pathlib.Path) -> None:
308 root, repo_id = _init_repo(tmp_path)
309 _make_commit(root, repo_id)
310 result = _invoke(["release", "add", "not-semver"], root)
311 assert result.exit_code != 0
312
313
314 def test_add_duplicate_error_to_stderr(tmp_path: pathlib.Path) -> None:
315 root, repo_id = _init_repo(tmp_path)
316 _make_commit(root, repo_id)
317 _invoke(["release", "add", "v1.0.0"], root)
318 result = _invoke(["release", "add", "v1.0.0"], root)
319 assert result.exit_code != 0
320 assert "already exists" in result.stderr.lower()
321
322
323 def test_show_not_found_error(tmp_path: pathlib.Path) -> None:
324 root, _ = _init_repo(tmp_path)
325 result = _invoke(["release", "read", "v99.0.0"], root)
326 assert result.exit_code != 0
327 assert "not found" in result.stderr.lower()
328
329
330 def test_push_not_found_locally_error(tmp_path: pathlib.Path) -> None:
331 root, _ = _init_repo(tmp_path)
332 result = _invoke(["release", "push", "v99.0.0", "--remote", "origin"], root)
333 assert result.exit_code != 0
334 assert "not found" in result.stderr.lower()
335
336
337 def test_delete_not_found_error(tmp_path: pathlib.Path) -> None:
338 root, _ = _init_repo(tmp_path)
339 result = _invoke(["release", "delete", "v99.0.0", "--yes"], root)
340 assert result.exit_code != 0
341 assert "not found" in result.stderr.lower()
342
343
344 # ---------------------------------------------------------------------------
345 # JSON schema: --json on add
346 # ---------------------------------------------------------------------------
347
348
349 def test_add_json_output_schema(tmp_path: pathlib.Path) -> None:
350 root, repo_id = _init_repo(tmp_path)
351 _make_commit(root, repo_id, message="feat: new", sem_ver_bump="minor")
352 result = _invoke(["release", "add", "v1.0.0", "--title", "First", "--json"], root)
353 assert result.exit_code == 0, result.output
354 data = json.loads(result.output)
355 assert data["tag"] == "v1.0.0"
356 assert data["channel"] == "stable"
357 assert isinstance(data["release_id"], str)
358 assert isinstance(data["changelog"], list)
359 assert data["is_draft"] is False
360
361
362 def test_add_draft_json_output(tmp_path: pathlib.Path) -> None:
363 root, repo_id = _init_repo(tmp_path)
364 _make_commit(root, repo_id)
365 result = _invoke(
366 ["release", "add", "v1.0.0-alpha.1", "--draft", "--json"], root
367 )
368 assert result.exit_code == 0, result.output
369 data = json.loads(result.output)
370 assert data["is_draft"] is True
371 assert data["channel"] == "alpha"
372
373
374 # ---------------------------------------------------------------------------
375 # JSON schema: --json on show
376 # ---------------------------------------------------------------------------
377
378
379 def test_show_json_schema(tmp_path: pathlib.Path) -> None:
380 root, repo_id = _init_repo(tmp_path)
381 _write_release(root, repo_id, "v2.0.0")
382 result = _invoke(["release", "read", "v2.0.0", "--json"], root)
383 assert result.exit_code == 0, result.output
384 parsed = _parse_show(result.output)
385 assert parsed["tag"] == "v2.0.0"
386 assert parsed["channel"] == "stable"
387 assert parsed["is_draft"] is False
388
389
390 # ---------------------------------------------------------------------------
391 # JSON schema: --json on list
392 # ---------------------------------------------------------------------------
393
394
395 def test_list_json_schema(tmp_path: pathlib.Path) -> None:
396 root, repo_id = _init_repo(tmp_path)
397 _write_release(root, repo_id, "v1.0.0")
398 _write_release(root, repo_id, "v1.1.0-beta.1")
399 result = _invoke(["release", "list", "--include-drafts", "--json"], root)
400 assert result.exit_code == 0, result.output
401 data = json.loads(result.output)
402 releases = data["releases"]
403 assert isinstance(releases, list)
404 assert len(releases) >= 1
405 tags = {r["tag"] for r in releases}
406 assert "v1.0.0" in tags
407
408
409 def test_list_empty_json(tmp_path: pathlib.Path) -> None:
410 root, _ = _init_repo(tmp_path)
411 result = _invoke(["release", "list", "--json"], root)
412 assert result.exit_code == 0
413 data = json.loads(result.output)
414 assert data["releases"] == []
415
416
417 # ---------------------------------------------------------------------------
418 # JSON schema: --dry-run push
419 # ---------------------------------------------------------------------------
420
421
422 def test_push_dry_run_json_schema(tmp_path: pathlib.Path) -> None:
423 root, repo_id = _init_repo(tmp_path)
424 _write_release(root, repo_id, "v1.0.0")
425 result = _invoke(
426 ["release", "push", "v1.0.0", "--remote", "origin", "--dry-run", "--json"], root
427 )
428 assert result.exit_code == 0, result.output
429 parsed = _parse_push(result.output)
430 assert parsed["status"] == "dry_run"
431 assert parsed["tag"] == "v1.0.0"
432 assert parsed["remote"] == "origin"
433 assert parsed["dry_run"] is True
434
435
436 def test_push_dry_run_no_network_call(tmp_path: pathlib.Path) -> None:
437 """--dry-run push must not call transport.create_release."""
438 root, repo_id = _init_repo(tmp_path)
439 _write_release(root, repo_id, "v1.0.0")
440 with patch("muse.cli.commands.release.make_transport") as mock_transport:
441 result = _invoke(
442 ["release", "push", "v1.0.0", "--remote", "origin", "--dry-run"], root
443 )
444 assert result.exit_code == 0
445 # make_transport should not be called at all in dry-run mode.
446 mock_transport.assert_not_called()
447
448
449 def test_push_dry_run_text_output(tmp_path: pathlib.Path) -> None:
450 root, repo_id = _init_repo(tmp_path)
451 _write_release(root, repo_id, "v1.0.0")
452 result = _invoke(["release", "push", "v1.0.0", "--remote", "origin", "--dry-run"], root)
453 assert result.exit_code == 0
454 assert "dry-run" in result.output.lower() or "would push" in result.output.lower()
455 assert "v1.0.0" in result.output
456
457
458 # ---------------------------------------------------------------------------
459 # JSON schema: --dry-run delete
460 # ---------------------------------------------------------------------------
461
462
463 def test_delete_dry_run_json_schema(tmp_path: pathlib.Path) -> None:
464 root, repo_id = _init_repo(tmp_path)
465 _write_release(root, repo_id, "v1.0.0", is_draft=False)
466 result = _invoke(
467 ["release", "delete", "v1.0.0", "--dry-run", "--json"], root
468 )
469 assert result.exit_code == 0, result.output
470 parsed = _parse_delete(result.output)
471 assert parsed["status"] == "dry_run"
472 assert parsed["tag"] == "v1.0.0"
473 assert parsed["was_draft"] is False
474 assert parsed["remote_retracted"] is False
475 assert parsed["dry_run"] is True
476
477
478 def test_delete_dry_run_no_deletion(tmp_path: pathlib.Path) -> None:
479 """--dry-run delete must not remove the release record."""
480 root, repo_id = _init_repo(tmp_path)
481 _write_release(root, repo_id, "v1.0.0", is_draft=False)
482 result = _invoke(["release", "delete", "v1.0.0", "--dry-run"], root)
483 assert result.exit_code == 0
484 # Release must still exist.
485 assert get_release_for_tag(root, repo_id, "v1.0.0") is not None
486
487
488 def test_delete_dry_run_text_output(tmp_path: pathlib.Path) -> None:
489 root, repo_id = _init_repo(tmp_path)
490 _write_release(root, repo_id, "v1.0.0-alpha.1", is_draft=True)
491 result = _invoke(["release", "delete", "v1.0.0-alpha.1", "--dry-run"], root)
492 assert result.exit_code == 0
493 assert "v1.0.0-alpha.1" in result.output
494 assert "dry-run" in result.output.lower() or "would delete" in result.output.lower()
495
496
497 def test_delete_draft_dry_run_schema(tmp_path: pathlib.Path) -> None:
498 root, repo_id = _init_repo(tmp_path)
499 _write_release(root, repo_id, "v1.0.0-beta.1", is_draft=True)
500 result = _invoke(
501 ["release", "delete", "v1.0.0-beta.1", "--dry-run", "--json"], root
502 )
503 assert result.exit_code == 0
504 parsed = _parse_delete(result.output)
505 assert parsed["was_draft"] is True
506
507
508 # ---------------------------------------------------------------------------
509 # JSON schema: delete --yes --json
510 # ---------------------------------------------------------------------------
511
512
513 def test_delete_yes_json_schema(tmp_path: pathlib.Path) -> None:
514 root, repo_id = _init_repo(tmp_path)
515 _write_release(root, repo_id, "v1.0.0", is_draft=False)
516 result = _invoke(["release", "delete", "v1.0.0", "--yes", "--json"], root)
517 assert result.exit_code == 0, result.output
518 parsed = _parse_delete(result.output)
519 assert parsed["status"] == "deleted"
520 assert parsed["tag"] == "v1.0.0"
521 assert parsed["was_draft"] is False
522 assert parsed["remote_retracted"] is False
523 assert parsed["dry_run"] is False
524
525
526 def test_delete_draft_yes_json_schema(tmp_path: pathlib.Path) -> None:
527 root, repo_id = _init_repo(tmp_path)
528 _write_release(root, repo_id, "v1.0.0-alpha.1", is_draft=True)
529 result = _invoke(
530 ["release", "delete", "v1.0.0-alpha.1", "--yes", "--json"], root
531 )
532 assert result.exit_code == 0, result.output
533 parsed = _parse_delete(result.output)
534 assert parsed["status"] == "deleted"
535 assert parsed["was_draft"] is True
536
537
538 # ---------------------------------------------------------------------------
539 # --commit alias for --ref on add
540 # ---------------------------------------------------------------------------
541
542
543 def test_add_commit_alias_for_ref(tmp_path: pathlib.Path) -> None:
544 root, repo_id = _init_repo(tmp_path)
545 commit_id = _make_commit(root, repo_id, message="chore: setup")
546 result = _invoke(
547 ["release", "add", "v1.0.0", "--commit", commit_id], root
548 )
549 assert result.exit_code == 0, result.output
550
551
552 # ---------------------------------------------------------------------------
553 # Integration: full lifecycle
554 # ---------------------------------------------------------------------------
555
556
557 def test_full_lifecycle_add_show_list_delete(tmp_path: pathlib.Path) -> None:
558 root, repo_id = _init_repo(tmp_path)
559 _make_commit(root, repo_id, message="feat: init")
560
561 # Add
562 add_result = _invoke(
563 ["release", "add", "v1.0.0", "--title", "First release", "--json"], root
564 )
565 assert add_result.exit_code == 0, add_result.output
566 add_data = json.loads(add_result.output)
567 assert add_data["tag"] == "v1.0.0"
568
569 # Show
570 show_result = _invoke(["release", "read", "v1.0.0", "--json"], root)
571 assert show_result.exit_code == 0
572 show_data = _parse_show(show_result.output)
573 assert show_data["tag"] == "v1.0.0"
574
575 # List
576 list_result = _invoke(["release", "list", "--json"], root)
577 assert list_result.exit_code == 0
578 list_data = json.loads(list_result.output)["releases"]
579 assert any(r["tag"] == "v1.0.0" for r in list_data)
580
581 # Delete
582 del_result = _invoke(["release", "delete", "v1.0.0", "--yes", "--json"], root)
583 assert del_result.exit_code == 0
584 del_data = _parse_delete(del_result.output)
585 assert del_data["status"] == "deleted"
586
587 # Confirm gone
588 list_after = _invoke(["release", "list", "--json"], root)
589 assert list_after.exit_code == 0
590 assert json.loads(list_after.output)["releases"] == []
591
592
593 def test_lifecycle_draft_to_promoted(tmp_path: pathlib.Path) -> None:
594 """Create a draft, verify it's excluded from list by default, then delete it."""
595 root, repo_id = _init_repo(tmp_path)
596 _make_commit(root, repo_id)
597
598 _invoke(["release", "add", "v1.0.0-rc.1", "--draft"], root)
599
600 # Not in default list (no --include-drafts).
601 no_draft = _invoke(["release", "list", "--json"], root)
602 data = json.loads(no_draft.output)["releases"]
603 assert all(r["tag"] != "v1.0.0-rc.1" for r in data)
604
605 # Visible with --include-drafts.
606 with_drafts = _invoke(["release", "list", "--include-drafts", "--json"], root)
607 data2 = json.loads(with_drafts.output)["releases"]
608 assert any(r["tag"] == "v1.0.0-rc.1" for r in data2)
609
610 # Delete draft.
611 del_result = _invoke(["release", "delete", "v1.0.0-rc.1", "--yes"], root)
612 assert del_result.exit_code == 0
613
614
615 def test_channel_filter_integration(tmp_path: pathlib.Path) -> None:
616 root, repo_id = _init_repo(tmp_path)
617 _write_release(root, repo_id, "v1.0.0")
618 _write_release(root, repo_id, "v1.1.0-beta.1")
619
620 stable = _invoke(["release", "list", "--channel", "stable", "--json"], root)
621 assert stable.exit_code == 0
622 stable_data = json.loads(stable.output)["releases"]
623 assert all(r["channel"] == "stable" for r in stable_data)
624 assert any(r["tag"] == "v1.0.0" for r in stable_data)
625
626 beta = _invoke(["release", "list", "--channel", "beta", "--json"], root)
627 assert beta.exit_code == 0
628 beta_data = json.loads(beta.output)["releases"]
629 assert all(r["channel"] == "beta" for r in beta_data)
630
631
632 # ---------------------------------------------------------------------------
633 # E2E: help output
634 # ---------------------------------------------------------------------------
635
636
637 def test_release_help() -> None:
638 result = runner.invoke(None, ["release", "--help"])
639 assert result.exit_code == 0
640
641
642 def test_add_help() -> None:
643 result = runner.invoke(None, ["release", "add", "--help"])
644 assert result.exit_code == 0
645 assert "--json" in result.output
646 assert "--draft" in result.output
647 assert "--channel" in result.output
648
649
650 def test_push_help() -> None:
651 result = runner.invoke(None, ["release", "push", "--help"])
652 assert result.exit_code == 0
653 assert "--json" in result.output
654 assert "--dry-run" in result.output
655
656
657 def test_delete_help() -> None:
658 result = runner.invoke(None, ["release", "delete", "--help"])
659 assert result.exit_code == 0
660 assert "--json" in result.output
661 assert "--dry-run" in result.output
662 assert "--yes" in result.output
663
664
665 # ---------------------------------------------------------------------------
666 # E2E: text output correctness
667 # ---------------------------------------------------------------------------
668
669
670 def test_add_text_output(tmp_path: pathlib.Path) -> None:
671 root, repo_id = _init_repo(tmp_path)
672 _make_commit(root, repo_id)
673 result = _invoke(["release", "add", "v1.2.3", "--title", "Summer drop"], root)
674 assert result.exit_code == 0
675 assert "v1.2.3" in result.output
676
677
678 def test_delete_text_output(tmp_path: pathlib.Path) -> None:
679 root, repo_id = _init_repo(tmp_path)
680 _write_release(root, repo_id, "v1.0.0")
681 result = _invoke(["release", "delete", "v1.0.0", "--yes"], root)
682 assert result.exit_code == 0
683 assert "deleted" in result.output.lower()
684
685
686 def test_push_dry_run_text_mentions_tag(tmp_path: pathlib.Path) -> None:
687 root, repo_id = _init_repo(tmp_path)
688 _write_release(root, repo_id, "v1.0.0")
689 result = _invoke(["release", "push", "v1.0.0", "--remote", "origin", "--dry-run"], root)
690 assert result.exit_code == 0
691 assert "v1.0.0" in result.output
692
693
694 # ---------------------------------------------------------------------------
695 # Stress: 50 releases, list all, concurrent reads
696 # ---------------------------------------------------------------------------
697
698
699 def test_stress_50_releases_list(tmp_path: pathlib.Path) -> None:
700 root, repo_id = _init_repo(tmp_path)
701 for i in range(50):
702 _write_release(root, repo_id, f"v1.{i}.0")
703 releases = list_releases(root, repo_id)
704 assert len(releases) == 50
705
706
707 def test_stress_list_json_50(tmp_path: pathlib.Path) -> None:
708 root, repo_id = _init_repo(tmp_path)
709 for i in range(50):
710 _write_release(root, repo_id, f"v2.{i}.0")
711 result = _invoke(["release", "list", "--json"], root)
712 assert result.exit_code == 0
713 data = json.loads(result.output)
714 assert len(data["releases"]) == 50
715
716
717 def test_stress_concurrent_list_reads(tmp_path: pathlib.Path) -> None:
718 """Concurrent list_releases calls on the same repo must not crash."""
719 root, repo_id = _init_repo(tmp_path)
720 for i in range(20):
721 _write_release(root, repo_id, f"v3.{i}.0")
722 errors: list[str] = []
723
724 def _read() -> None:
725 try:
726 releases = list_releases(root, repo_id)
727 assert len(releases) == 20
728 except Exception as exc: # noqa: BLE001
729 errors.append(str(exc))
730
731 threads = [threading.Thread(target=_read) for _ in range(10)]
732 for t in threads:
733 t.start()
734 for t in threads:
735 t.join()
736
737 assert not errors, f"Concurrent failures: {errors}"
738
739
740 def test_stress_add_delete_cycle(tmp_path: pathlib.Path) -> None:
741 """Add and delete 20 releases in sequence; list must be empty at end."""
742 root, repo_id = _init_repo(tmp_path)
743 _make_commit(root, repo_id)
744 for i in range(20):
745 tag = f"v4.{i}.0"
746 add = _invoke(["release", "add", tag, "--json"], root)
747 assert add.exit_code == 0, add.output
748 rel_id = json.loads(add.output)["release_id"]
749 deleted = delete_release(root, repo_id, rel_id)
750 assert deleted
751 remaining = list_releases(root, repo_id)
752 assert remaining == []
753
754
755 # ===========================================================================
756 # TestReleaseAddExtended — 18 tests
757 # ===========================================================================
758
759
760 class TestReleaseAddExtended:
761 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
762 root, repo_id = _init_repo(tmp_path)
763 _make_commit(root, repo_id)
764 result = _invoke(["release", "add", "v1.0.0"], root)
765 assert result.exit_code == 0
766
767 def test_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
768 result = _invoke(["release", "add", "v1.0.0"], tmp_path)
769 assert result.exit_code == 2
770
771 def test_invalid_semver_exits_1(self, tmp_path: pathlib.Path) -> None:
772 root, repo_id = _init_repo(tmp_path)
773 _make_commit(root, repo_id)
774 result = _invoke(["release", "add", "not-semver"], root)
775 assert result.exit_code == 1
776
777 def test_duplicate_tag_exits_1(self, tmp_path: pathlib.Path) -> None:
778 root, repo_id = _init_repo(tmp_path)
779 _make_commit(root, repo_id)
780 _invoke(["release", "add", "v1.0.0"], root)
781 result = _invoke(["release", "add", "v1.0.0"], root)
782 assert result.exit_code == 1
783
784 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
785 """-j must produce identical JSON output to --json."""
786 root, repo_id = _init_repo(tmp_path)
787 _make_commit(root, repo_id)
788 r1 = _invoke(["release", "add", "v1.0.0", "--json"], root)
789 assert r1.exit_code == 0
790 root2 = tmp_path / "r2"
791 root2.mkdir()
792 root2b, repo_id2 = _init_repo(root2)
793 _make_commit(root2b, repo_id2)
794 r2 = _invoke(["release", "add", "v1.0.0", "-j"], root2b)
795 assert r2.exit_code == 0
796 d1, d2 = json.loads(r1.output), json.loads(r2.output)
797 assert d1.keys() == d2.keys()
798
799 def test_json_compact_no_indent(self, tmp_path: pathlib.Path) -> None:
800 root, repo_id = _init_repo(tmp_path)
801 _make_commit(root, repo_id)
802 result = _invoke(["release", "add", "v1.0.0", "--json"], root)
803 assert result.exit_code == 0
804 assert "\n" not in result.output.strip()
805
806 def test_json_schema_all_key_fields(self, tmp_path: pathlib.Path) -> None:
807 root, repo_id = _init_repo(tmp_path)
808 _make_commit(root, repo_id)
809 result = _invoke(["release", "add", "v1.0.0", "--json"], root)
810 assert result.exit_code == 0
811 data = json.loads(result.output)
812 for field in ("tag", "channel", "commit_id", "snapshot_id",
813 "release_id", "is_draft", "changelog"):
814 assert field in data, f"Missing field: {field}"
815
816 def test_channel_inferred_stable_no_pre(self, tmp_path: pathlib.Path) -> None:
817 root, repo_id = _init_repo(tmp_path)
818 _make_commit(root, repo_id)
819 data = json.loads(_invoke(["release", "add", "v1.0.0", "--json"], root).output)
820 assert data["channel"] == "stable"
821
822 def test_channel_inferred_beta(self, tmp_path: pathlib.Path) -> None:
823 root, repo_id = _init_repo(tmp_path)
824 _make_commit(root, repo_id)
825 data = json.loads(_invoke(["release", "add", "v1.0.0-beta.1", "--json"], root).output)
826 assert data["channel"] == "beta"
827
828 def test_channel_inferred_alpha(self, tmp_path: pathlib.Path) -> None:
829 root, repo_id = _init_repo(tmp_path)
830 _make_commit(root, repo_id)
831 data = json.loads(_invoke(["release", "add", "v1.0.0-alpha.1", "--json"], root).output)
832 assert data["channel"] == "alpha"
833
834 def test_channel_override(self, tmp_path: pathlib.Path) -> None:
835 """Explicit --channel overrides semver inference."""
836 root, repo_id = _init_repo(tmp_path)
837 _make_commit(root, repo_id)
838 data = json.loads(
839 _invoke(["release", "add", "v1.0.0", "--channel", "beta", "--json"], root).output
840 )
841 assert data["channel"] == "beta"
842
843 def test_unknown_channel_exits_1(self, tmp_path: pathlib.Path) -> None:
844 root, repo_id = _init_repo(tmp_path)
845 _make_commit(root, repo_id)
846 result = _invoke(["release", "add", "v1.0.0", "--channel", "canary"], root)
847 assert result.exit_code != 0
848
849 def test_draft_flag_in_json(self, tmp_path: pathlib.Path) -> None:
850 root, repo_id = _init_repo(tmp_path)
851 _make_commit(root, repo_id)
852 data = json.loads(
853 _invoke(["release", "add", "v1.0.0-alpha.1", "--draft", "--json"], root).output
854 )
855 assert data["is_draft"] is True
856
857 def test_no_draft_by_default(self, tmp_path: pathlib.Path) -> None:
858 root, repo_id = _init_repo(tmp_path)
859 _make_commit(root, repo_id)
860 data = json.loads(_invoke(["release", "add", "v1.0.0", "--json"], root).output)
861 assert data["is_draft"] is False
862
863 def test_changelog_list_in_json(self, tmp_path: pathlib.Path) -> None:
864 root, repo_id = _init_repo(tmp_path)
865 _make_commit(root, repo_id, message="feat: one", sem_ver_bump="minor")
866 _make_commit(root, repo_id, message="fix: two", sem_ver_bump="patch")
867 data = json.loads(_invoke(["release", "add", "v1.0.0", "--json"], root).output)
868 assert isinstance(data["changelog"], list)
869 assert len(data["changelog"]) == 2
870
871 def test_ref_not_found_exits_1(self, tmp_path: pathlib.Path) -> None:
872 root, repo_id = _init_repo(tmp_path)
873 _make_commit(root, repo_id)
874 result = _invoke(["release", "add", "v1.0.0", "--ref", "nonexistent"], root)
875 assert result.exit_code == 1
876
877 def test_help_mentions_agent_quickstart(self) -> None:
878 result = runner.invoke(None, ["release", "add", "--help"])
879 assert "Agent quickstart" in result.output
880
881 def test_help_mentions_exit_codes(self) -> None:
882 result = runner.invoke(None, ["release", "add", "--help"])
883 assert "Exit codes" in result.output
884
885
886 # ===========================================================================
887 # TestReleaseAddSecurity — 6 tests
888 # ===========================================================================
889
890
891 class TestReleaseAddSecurity:
892 def test_ansi_in_title_stripped_text(self, tmp_path: pathlib.Path) -> None:
893 root, repo_id = _init_repo(tmp_path)
894 _make_commit(root, repo_id)
895 _invoke(["release", "add", "v1.0.0", "--title", "\x1b[1mBold\x1b[0m"], root)
896 show = _invoke(["release", "read", "v1.0.0"], root)
897 assert "\x1b" not in show.output
898
899 def test_ansi_in_body_stripped_text(self, tmp_path: pathlib.Path) -> None:
900 root, repo_id = _init_repo(tmp_path)
901 _make_commit(root, repo_id)
902 _invoke(["release", "add", "v1.0.0", "--body", "\x1b[31mDanger\x1b[0m"], root)
903 show = _invoke(["release", "read", "v1.0.0"], root)
904 assert "\x1b" not in show.output
905
906 def test_control_char_in_title_stripped_text(self, tmp_path: pathlib.Path) -> None:
907 root, repo_id = _init_repo(tmp_path)
908 _make_commit(root, repo_id)
909 _invoke(["release", "add", "v1.0.0", "--title", "Evil\x07Bell"], root)
910 show = _invoke(["release", "read", "v1.0.0"], root)
911 assert "\x07" not in show.output
912
913 def test_no_json_outside_repo(self, tmp_path: pathlib.Path) -> None:
914 result = _invoke(["release", "add", "v1.0.0", "--json"], tmp_path)
915 assert result.exit_code == 2
916 assert not result.output.strip().startswith("{")
917
918 def test_no_traceback_invalid_semver(self, tmp_path: pathlib.Path) -> None:
919 root, repo_id = _init_repo(tmp_path)
920 _make_commit(root, repo_id)
921 result = _invoke(["release", "add", "not-valid"], root)
922 assert "Traceback" not in result.output
923
924 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path) -> None:
925 result = _invoke(["release", "add", "v1.0.0"], tmp_path)
926 assert result.exit_code == 2
927 assert "Traceback" not in result.output
928
929
930 # ===========================================================================
931 # TestReleaseAddStress — 3 tests
932 # ===========================================================================
933
934
935 class TestReleaseAddStress:
936 def test_20_sequential_patch_releases(self, tmp_path: pathlib.Path) -> None:
937 """20 sequentially added patch releases all succeed."""
938 root, repo_id = _init_repo(tmp_path)
939 _make_commit(root, repo_id)
940 for i in range(20):
941 result = _invoke(["release", "add", f"v1.0.{i}", "--json"], root)
942 assert result.exit_code == 0, f"v1.0.{i} failed: {result.output}"
943 assert len(list_releases(root, repo_id)) == 20
944
945 def test_20_releases_across_channels(self, tmp_path: pathlib.Path) -> None:
946 """Releases spanning all four channels are created correctly."""
947 root, repo_id = _init_repo(tmp_path)
948 _make_commit(root, repo_id)
949 tags = (
950 [f"v1.{i}.0" for i in range(5)]
951 + [f"v2.{i}.0-beta.1" for i in range(5)]
952 + [f"v3.{i}.0-alpha.1" for i in range(5)]
953 + [f"v4.{i}.0-nightly.1" for i in range(5)]
954 )
955 for tag in tags:
956 r = _invoke(["release", "add", tag, "--json"], root)
957 assert r.exit_code == 0, f"{tag}: {r.output}"
958 releases = list_releases(root, repo_id, include_drafts=True)
959 assert len(releases) == 20
960
961 def test_changelog_grows_with_commits(self, tmp_path: pathlib.Path) -> None:
962 """Changelog for each successive release only includes commits since prior."""
963 root, repo_id = _init_repo(tmp_path)
964 for i in range(5):
965 _make_commit(root, repo_id, message=f"feat: step {i}", sem_ver_bump="minor")
966 d1 = json.loads(_invoke(["release", "add", "v1.0.0", "--json"], root).output)
967 assert len(d1["changelog"]) == 5
968 for i in range(3):
969 _make_commit(root, repo_id, message=f"fix: patch {i}", sem_ver_bump="patch")
970 d2 = json.loads(_invoke(["release", "add", "v1.0.1", "--json"], root).output)
971 assert len(d2["changelog"]) == 3
972
973
974 # ===========================================================================
975 # TestReleaseListExtended — 18 tests
976 # ===========================================================================
977
978
979 class TestReleaseListExtended:
980 def test_exit_code_zero_empty(self, tmp_path: pathlib.Path) -> None:
981 root, _ = _init_repo(tmp_path)
982 assert _invoke(["release", "list"], root).exit_code == 0
983
984 def test_exit_code_zero_with_releases(self, tmp_path: pathlib.Path) -> None:
985 root, repo_id = _init_repo(tmp_path)
986 _write_release(root, repo_id, "v1.0.0")
987 assert _invoke(["release", "list"], root).exit_code == 0
988
989 def test_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
990 assert _invoke(["release", "list"], tmp_path).exit_code == 2
991
992 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
993 """-j must produce the same schema as --json (duration_ms varies between runs)."""
994 root, repo_id = _init_repo(tmp_path)
995 _write_release(root, repo_id, "v1.0.0")
996 r1 = _invoke(["release", "list", "--json"], root)
997 r2 = _invoke(["release", "list", "-j"], root)
998 assert r1.exit_code == 0 and r2.exit_code == 0
999 d1 = {k: v for k, v in json.loads(r1.output).items() if k not in {"duration_ms", "timestamp"}}
1000 d2 = {k: v for k, v in json.loads(r2.output).items() if k not in {"duration_ms", "timestamp"}}
1001 assert d1 == d2
1002
1003 def test_json_compact_no_indent(self, tmp_path: pathlib.Path) -> None:
1004 root, repo_id = _init_repo(tmp_path)
1005 _write_release(root, repo_id, "v1.0.0")
1006 result = _invoke(["release", "list", "--json"], root)
1007 assert result.exit_code == 0
1008 assert "\n" not in result.output.strip()
1009
1010 def test_json_empty_is_array(self, tmp_path: pathlib.Path) -> None:
1011 root, _ = _init_repo(tmp_path)
1012 data = json.loads(_invoke(["release", "list", "--json"], root).output)
1013 assert data["releases"] == []
1014
1015 def test_json_contains_all_key_fields(self, tmp_path: pathlib.Path) -> None:
1016 root, repo_id = _init_repo(tmp_path)
1017 _write_release(root, repo_id, "v1.0.0")
1018 data = json.loads(_invoke(["release", "list", "--json"], root).output)
1019 releases = data["releases"]
1020 assert len(releases) == 1
1021 rec = releases[0]
1022 for field in ("tag", "channel", "commit_id", "snapshot_id",
1023 "release_id", "is_draft"):
1024 assert field in rec, f"Missing field: {field}"
1025
1026 def test_drafts_excluded_by_default(self, tmp_path: pathlib.Path) -> None:
1027 root, repo_id = _init_repo(tmp_path)
1028 _write_release(root, repo_id, "v1.0.0", is_draft=True)
1029 data = json.loads(_invoke(["release", "list", "--json"], root).output)
1030 assert data["releases"] == []
1031
1032 def test_drafts_included_with_flag(self, tmp_path: pathlib.Path) -> None:
1033 root, repo_id = _init_repo(tmp_path)
1034 _write_release(root, repo_id, "v1.0.0", is_draft=True)
1035 data = json.loads(
1036 _invoke(["release", "list", "--include-drafts", "--json"], root).output
1037 )
1038 releases = data["releases"]
1039 assert len(releases) == 1
1040 assert releases[0]["is_draft"] is True
1041
1042 def test_channel_filter_stable(self, tmp_path: pathlib.Path) -> None:
1043 root, repo_id = _init_repo(tmp_path)
1044 _write_release(root, repo_id, "v1.0.0")
1045 _write_release(root, repo_id, "v1.1.0-beta.1")
1046 data = json.loads(
1047 _invoke(["release", "list", "--channel", "stable", "--json"], root).output
1048 )
1049 releases = data["releases"]
1050 assert len(releases) == 1
1051 assert releases[0]["channel"] == "stable"
1052
1053 def test_channel_filter_beta(self, tmp_path: pathlib.Path) -> None:
1054 root, repo_id = _init_repo(tmp_path)
1055 _write_release(root, repo_id, "v1.0.0")
1056 _write_release(root, repo_id, "v1.1.0-beta.1")
1057 data = json.loads(
1058 _invoke(["release", "list", "--channel", "beta", "--json"], root).output
1059 )
1060 releases = data["releases"]
1061 assert len(releases) == 1
1062 assert releases[0]["channel"] == "beta"
1063
1064 def test_channel_filter_empty_returns_all(self, tmp_path: pathlib.Path) -> None:
1065 """No --channel flag returns all channels."""
1066 root, repo_id = _init_repo(tmp_path)
1067 _write_release(root, repo_id, "v1.0.0")
1068 _write_release(root, repo_id, "v1.1.0-beta.1")
1069 data = json.loads(_invoke(["release", "list", "--json"], root).output)
1070 assert len(data["releases"]) == 2
1071
1072 def test_text_shows_tag_and_channel(self, tmp_path: pathlib.Path) -> None:
1073 root, repo_id = _init_repo(tmp_path)
1074 _write_release(root, repo_id, "v2.0.0")
1075 result = _invoke(["release", "list"], root)
1076 assert result.exit_code == 0
1077 assert "v2.0.0" in result.output
1078 assert "stable" in result.output
1079
1080 def test_text_empty_message(self, tmp_path: pathlib.Path) -> None:
1081 root, _ = _init_repo(tmp_path)
1082 result = _invoke(["release", "list"], root)
1083 assert "No releases" in result.output
1084
1085 def test_remote_not_configured_exits_1(self, tmp_path: pathlib.Path) -> None:
1086 root, _ = _init_repo(tmp_path)
1087 result = _invoke(["release", "list", "--remote", "nosuchremote"], root)
1088 assert result.exit_code == 1
1089
1090 def test_multiple_releases_all_returned(self, tmp_path: pathlib.Path) -> None:
1091 root, repo_id = _init_repo(tmp_path)
1092 for i in range(5):
1093 _write_release(root, repo_id, f"v1.{i}.0")
1094 data = json.loads(_invoke(["release", "list", "--json"], root).output)
1095 assert len(data["releases"]) == 5
1096
1097 def test_help_mentions_agent_quickstart(self) -> None:
1098 result = runner.invoke(None, ["release", "list", "--help"])
1099 assert "Agent quickstart" in result.output
1100
1101 def test_help_mentions_exit_codes(self) -> None:
1102 result = runner.invoke(None, ["release", "list", "--help"])
1103 assert "Exit codes" in result.output
1104
1105
1106 # ===========================================================================
1107 # TestReleaseListSecurity — 6 tests
1108 # ===========================================================================
1109
1110
1111 class TestReleaseListSecurity:
1112 def test_ansi_in_title_stripped_text(self, tmp_path: pathlib.Path) -> None:
1113 root, repo_id = _init_repo(tmp_path)
1114 from muse.core.semver import ReleaseChannel
1115 sv = SemVerTag(major=1, minor=0, patch=0, pre="", build="")
1116 rec = ReleaseRecord(
1117 repo_id=repo_id,
1118 release_id=fake_id("release"),
1119 tag="v1.0.0",
1120 semver=sv,
1121 channel="stable",
1122 commit_id="a" * 64,
1123 snapshot_id="b" * 64,
1124 title="\x1b[31mEvil\x1b[0m",
1125 body="",
1126 changelog=[],
1127 )
1128 write_release(root, rec)
1129 result = _invoke(["release", "list"], root)
1130 assert result.exit_code == 0
1131 assert "\x1b" not in result.output
1132
1133 def test_control_char_in_title_stripped_text(self, tmp_path: pathlib.Path) -> None:
1134 root, repo_id = _init_repo(tmp_path)
1135 sv = SemVerTag(major=1, minor=0, patch=0, pre="", build="")
1136 rec = ReleaseRecord(
1137 repo_id=repo_id,
1138 release_id=fake_id("release"),
1139 tag="v1.0.0",
1140 semver=sv,
1141 channel="stable",
1142 commit_id="a" * 64,
1143 snapshot_id="b" * 64,
1144 title="Evil\x07Bell",
1145 body="",
1146 changelog=[],
1147 )
1148 write_release(root, rec)
1149 result = _invoke(["release", "list"], root)
1150 assert "\x07" not in result.output
1151
1152 def test_no_json_outside_repo(self, tmp_path: pathlib.Path) -> None:
1153 result = _invoke(["release", "list", "--json"], tmp_path)
1154 assert result.exit_code == 2
1155 assert not result.output.strip().startswith("[")
1156
1157 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path) -> None:
1158 result = _invoke(["release", "list"], tmp_path)
1159 assert result.exit_code == 2
1160 assert "Traceback" not in result.output
1161
1162 def test_no_traceback_unknown_remote(self, tmp_path: pathlib.Path) -> None:
1163 root, _ = _init_repo(tmp_path)
1164 result = _invoke(["release", "list", "--remote", "badremote"], root)
1165 assert "Traceback" not in result.output
1166
1167 def test_json_output_on_stdout(self, tmp_path: pathlib.Path) -> None:
1168 """JSON object goes to stdout on success."""
1169 root, repo_id = _init_repo(tmp_path)
1170 _write_release(root, repo_id, "v1.0.0")
1171 result = _invoke(["release", "list", "--json"], root)
1172 assert result.output.strip().startswith("{")
1173
1174
1175 # ===========================================================================
1176 # TestReleaseListStress — 3 tests
1177 # ===========================================================================
1178
1179
1180 class TestReleaseListStress:
1181 def test_100_releases_json(self, tmp_path: pathlib.Path) -> None:
1182 """100 releases returned correctly in JSON mode."""
1183 root, repo_id = _init_repo(tmp_path)
1184 for i in range(100):
1185 _write_release(root, repo_id, f"v1.{i}.0")
1186 data = json.loads(_invoke(["release", "list", "--json"], root).output)
1187 assert len(data["releases"]) == 100
1188
1189 def test_100_releases_text(self, tmp_path: pathlib.Path) -> None:
1190 """100 releases listed in text mode without error."""
1191 root, repo_id = _init_repo(tmp_path)
1192 for i in range(100):
1193 _write_release(root, repo_id, f"v2.{i}.0")
1194 result = _invoke(["release", "list"], root)
1195 assert result.exit_code == 0
1196 assert "v2.0.0" in result.output
1197
1198 def test_channel_filter_25_each(self, tmp_path: pathlib.Path) -> None:
1199 """25 releases per channel — filter returns exactly 25 each."""
1200 import hashlib as _hl
1201 root, repo_id = _init_repo(tmp_path)
1202 channels = [("stable", "v1.{}.0"), ("beta", "v2.{}.0-beta.1"),
1203 ("alpha", "v3.{}.0-alpha.1"), ("nightly", "v4.{}.0-nightly.1")]
1204 for _ch, tmpl in channels:
1205 for i in range(25):
1206 _write_release(root, repo_id, tmpl.format(i))
1207 for ch, _ in channels:
1208 data = json.loads(
1209 _invoke(["release", "list", "--channel", ch, "--json"], root).output
1210 )
1211 assert len(data["releases"]) == 25, f"Expected 25 for channel {ch}, got {len(data['releases'])}"
1212
1213
1214 # ===========================================================================
1215 # TestReleaseShowExtended — 18 tests
1216 # ===========================================================================
1217
1218
1219 class TestReleaseShowExtended:
1220 def test_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
1221 root, repo_id = _init_repo(tmp_path)
1222 _write_release(root, repo_id, "v1.0.0")
1223 assert _invoke(["release", "read", "v1.0.0"], root).exit_code == 0
1224
1225 def test_not_found_exits_4(self, tmp_path: pathlib.Path) -> None:
1226 root, _ = _init_repo(tmp_path)
1227 assert _invoke(["release", "read", "v99.0.0"], root).exit_code == 4
1228
1229 def test_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1230 assert _invoke(["release", "read", "v1.0.0"], tmp_path).exit_code == 2
1231
1232 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
1233 root, repo_id = _init_repo(tmp_path)
1234 _write_release(root, repo_id, "v1.0.0")
1235 r1 = _invoke(["release", "read", "v1.0.0", "--json"], root)
1236 r2 = _invoke(["release", "read", "v1.0.0", "-j"], root)
1237 assert r1.exit_code == 0 and r2.exit_code == 0
1238 d1 = {k: v for k, v in json.loads(r1.output).items() if k not in {"duration_ms", "timestamp"}}
1239 d2 = {k: v for k, v in json.loads(r2.output).items() if k not in {"duration_ms", "timestamp"}}
1240 assert d1 == d2
1241
1242 def test_json_compact_no_indent(self, tmp_path: pathlib.Path) -> None:
1243 root, repo_id = _init_repo(tmp_path)
1244 _write_release(root, repo_id, "v1.0.0")
1245 result = _invoke(["release", "read", "v1.0.0", "--json"], root)
1246 assert result.exit_code == 0
1247 assert "\n" not in result.output.strip()
1248
1249 def test_json_is_object_not_array(self, tmp_path: pathlib.Path) -> None:
1250 root, repo_id = _init_repo(tmp_path)
1251 _write_release(root, repo_id, "v1.0.0")
1252 result = _invoke(["release", "read", "v1.0.0", "--json"], root)
1253 assert result.output.strip().startswith("{")
1254
1255 def test_json_all_key_fields(self, tmp_path: pathlib.Path) -> None:
1256 root, repo_id = _init_repo(tmp_path)
1257 _write_release(root, repo_id, "v1.0.0")
1258 data = json.loads(_invoke(["release", "read", "v1.0.0", "--json"], root).output)
1259 for field in ("tag", "channel", "commit_id", "snapshot_id",
1260 "release_id", "is_draft", "changelog", "semver",
1261 "title", "body", "created_at"):
1262 assert field in data, f"Missing field: {field}"
1263
1264 def test_text_shows_tag(self, tmp_path: pathlib.Path) -> None:
1265 root, repo_id = _init_repo(tmp_path)
1266 _write_release(root, repo_id, "v2.3.4")
1267 assert "v2.3.4" in _invoke(["release", "read", "v2.3.4"], root).output
1268
1269 def test_text_shows_channel(self, tmp_path: pathlib.Path) -> None:
1270 root, repo_id = _init_repo(tmp_path)
1271 _write_release(root, repo_id, "v1.0.0")
1272 assert "stable" in _invoke(["release", "read", "v1.0.0"], root).output
1273
1274 def test_text_shows_commit(self, tmp_path: pathlib.Path) -> None:
1275 root, repo_id = _init_repo(tmp_path)
1276 _write_release(root, repo_id, "v1.0.0")
1277 result = _invoke(["release", "read", "v1.0.0"], root)
1278 assert "Commit" in result.output
1279
1280 def test_text_shows_created_at(self, tmp_path: pathlib.Path) -> None:
1281 root, repo_id = _init_repo(tmp_path)
1282 _write_release(root, repo_id, "v1.0.0")
1283 assert "Created" in _invoke(["release", "read", "v1.0.0"], root).output
1284
1285 def test_text_shows_title_when_set(self, tmp_path: pathlib.Path) -> None:
1286 root, repo_id = _init_repo(tmp_path)
1287 _make_commit(root, repo_id)
1288 _invoke(["release", "add", "v1.0.0", "--title", "Summer Drop"], root)
1289 assert "Summer Drop" in _invoke(["release", "read", "v1.0.0"], root).output
1290
1291 def test_text_draft_label(self, tmp_path: pathlib.Path) -> None:
1292 root, repo_id = _init_repo(tmp_path)
1293 _write_release(root, repo_id, "v1.0.0", is_draft=True)
1294 assert "[DRAFT]" in _invoke(["release", "read", "v1.0.0"], root).output
1295
1296 def test_text_no_draft_label_for_non_draft(self, tmp_path: pathlib.Path) -> None:
1297 root, repo_id = _init_repo(tmp_path)
1298 _write_release(root, repo_id, "v1.0.0", is_draft=False)
1299 assert "[DRAFT]" not in _invoke(["release", "read", "v1.0.0"], root).output
1300
1301 def test_text_changelog_shows_commit_count(self, tmp_path: pathlib.Path) -> None:
1302 root, repo_id = _init_repo(tmp_path)
1303 _make_commit(root, repo_id, message="feat: a", sem_ver_bump="minor")
1304 _make_commit(root, repo_id, message="fix: b", sem_ver_bump="patch")
1305 _invoke(["release", "add", "v1.0.0"], root)
1306 result = _invoke(["release", "read", "v1.0.0"], root)
1307 assert "2 commits" in result.output
1308
1309 def test_changelog_truncated_at_20_text(self, tmp_path: pathlib.Path) -> None:
1310 """Changelogs > 20 entries show a '… and N more' footer."""
1311 root, repo_id = _init_repo(tmp_path)
1312 for i in range(25):
1313 _make_commit(root, repo_id, message=f"feat: step {i}", sem_ver_bump="minor")
1314 _invoke(["release", "add", "v1.0.0"], root)
1315 result = _invoke(["release", "read", "v1.0.0"], root)
1316 assert "more" in result.output
1317
1318 def test_help_mentions_agent_quickstart(self) -> None:
1319 assert "Agent quickstart" in runner.invoke(None, ["release", "read", "--help"]).output
1320
1321 def test_help_mentions_exit_codes(self) -> None:
1322 assert "Exit codes" in runner.invoke(None, ["release", "read", "--help"]).output
1323
1324
1325 # ===========================================================================
1326 # TestReleaseShowSecurity — 6 tests
1327 # ===========================================================================
1328
1329
1330 class TestReleaseShowSecurity:
1331 def _write_crafted(
1332 self,
1333 root: pathlib.Path,
1334 repo_id: str,
1335 tag: str = "v1.0.0",
1336 title: str = "",
1337 body: str = "",
1338 channel: str = "stable",
1339 ) -> None:
1340 sv = SemVerTag(major=1, minor=0, patch=0, pre="", build="")
1341 rec = ReleaseRecord(
1342 repo_id=repo_id,
1343 release_id=fake_id("release"),
1344 tag=tag,
1345 semver=sv,
1346 channel=channel,
1347 commit_id="a" * 64,
1348 snapshot_id="b" * 64,
1349 title=title,
1350 body=body,
1351 changelog=[],
1352 )
1353 write_release(root, rec)
1354
1355 def test_ansi_in_title_stripped(self, tmp_path: pathlib.Path) -> None:
1356 root, repo_id = _init_repo(tmp_path)
1357 self._write_crafted(root, repo_id, title="\x1b[31mEvil\x1b[0m")
1358 assert "\x1b" not in _invoke(["release", "read", "v1.0.0"], root).output
1359
1360 def test_ansi_in_body_stripped(self, tmp_path: pathlib.Path) -> None:
1361 root, repo_id = _init_repo(tmp_path)
1362 self._write_crafted(root, repo_id, body="\x1b[32mInjected\x1b[0m")
1363 assert "\x1b" not in _invoke(["release", "read", "v1.0.0"], root).output
1364
1365 def test_control_char_in_title_stripped(self, tmp_path: pathlib.Path) -> None:
1366 root, repo_id = _init_repo(tmp_path)
1367 self._write_crafted(root, repo_id, title="Evil\x07Bell")
1368 assert "\x07" not in _invoke(["release", "read", "v1.0.0"], root).output
1369
1370 def test_no_json_outside_repo(self, tmp_path: pathlib.Path) -> None:
1371 result = _invoke(["release", "read", "v1.0.0", "--json"], tmp_path)
1372 assert result.exit_code == 2
1373 assert not result.output.strip().startswith("{")
1374
1375 def test_no_traceback_not_found(self, tmp_path: pathlib.Path) -> None:
1376 root, _ = _init_repo(tmp_path)
1377 result = _invoke(["release", "read", "v99.0.0"], root)
1378 assert "Traceback" not in result.output
1379
1380 def test_no_traceback_outside_repo(self, tmp_path: pathlib.Path) -> None:
1381 result = _invoke(["release", "read", "v1.0.0"], tmp_path)
1382 assert "Traceback" not in result.output
1383
1384
1385 # ===========================================================================
1386 # TestReleaseShowStress — 3 tests
1387 # ===========================================================================
1388
1389
1390 class TestReleaseShowStress:
1391 def test_show_release_with_25_changelog_entries(self, tmp_path: pathlib.Path) -> None:
1392 """Show handles a 25-entry changelog — truncation footer appears."""
1393 root, repo_id = _init_repo(tmp_path)
1394 for i in range(25):
1395 _make_commit(root, repo_id, message=f"feat: item {i}", sem_ver_bump="minor")
1396 _invoke(["release", "add", "v1.0.0"], root)
1397 result = _invoke(["release", "read", "v1.0.0"], root)
1398 assert result.exit_code == 0
1399 assert "25 commits" in result.output
1400 assert "more" in result.output
1401
1402 def test_show_20_distinct_releases(self, tmp_path: pathlib.Path) -> None:
1403 """show on each of 20 distinct releases all exit 0."""
1404 root, repo_id = _init_repo(tmp_path)
1405 for i in range(20):
1406 _write_release(root, repo_id, f"v1.{i}.0")
1407 for i in range(20):
1408 r = _invoke(["release", "read", f"v1.{i}.0", "--json"], root)
1409 assert r.exit_code == 0, f"v1.{i}.0 failed: {r.output}"
1410 assert json.loads(r.output)["tag"] == f"v1.{i}.0"
1411
1412 def test_concurrent_show_reads(self, tmp_path: pathlib.Path) -> None:
1413 """Concurrent get_release_for_tag calls on the same release must not crash."""
1414 root, repo_id = _init_repo(tmp_path)
1415 _write_release(root, repo_id, "v1.0.0")
1416 errors: list[str] = []
1417
1418 def _do_read() -> None:
1419 try:
1420 rec = get_release_for_tag(root, repo_id, "v1.0.0")
1421 assert rec is not None
1422 assert rec.tag == "v1.0.0"
1423 except Exception as exc: # noqa: BLE001
1424 errors.append(str(exc))
1425
1426 threads = [threading.Thread(target=_do_read) for _ in range(10)]
1427 for t in threads:
1428 t.start()
1429 for t in threads:
1430 t.join()
1431 assert not errors, f"Concurrent failures: {errors}"
1432
1433
1434 # ---------------------------------------------------------------------------
1435 # Extended / Security / Stress tests for ``muse release push``
1436 # ---------------------------------------------------------------------------
1437
1438
1439 class TestReleasePushExtended:
1440 """Unit, integration, and edge-case tests for ``muse release push``."""
1441
1442 def test_push_help_contains_agent_quickstart(self) -> None:
1443 result = runner.invoke(None, ["release", "push", "--help"])
1444 assert result.exit_code == 0
1445 assert "quickstart" in result.output.lower() or "muse release push v" in result.output
1446
1447 def test_push_help_contains_json_schema(self) -> None:
1448 result = runner.invoke(None, ["release", "push", "--help"])
1449 assert result.exit_code == 0
1450 assert "release_id" in result.output
1451
1452 def test_push_help_contains_exit_codes(self) -> None:
1453 result = runner.invoke(None, ["release", "push", "--help"])
1454 assert result.exit_code == 0
1455 assert "exit code" in result.output.lower() or "0 —" in result.output
1456
1457 def test_push_j_alias_dry_run(self, tmp_path: pathlib.Path) -> None:
1458 """-j is an alias for --json."""
1459 root, repo_id = _init_repo(tmp_path)
1460 _write_release(root, repo_id, "v1.0.0")
1461 result = _invoke(["release", "push", "v1.0.0", "--remote", "origin", "--dry-run", "-j"], root)
1462 assert result.exit_code == 0
1463 parsed = _parse_push(result.output)
1464 assert parsed["status"] == "dry_run"
1465 assert parsed["dry_run"] is True
1466
1467 def test_push_dry_run_json_release_id_is_local(self, tmp_path: pathlib.Path) -> None:
1468 """dry-run JSON includes the local release_id (no network call)."""
1469 root, repo_id = _init_repo(tmp_path)
1470 rec = _write_release(root, repo_id, "v1.0.0")
1471 result = _invoke(
1472 ["release", "push", "v1.0.0", "--remote", "origin", "--dry-run", "--json"], root
1473 )
1474 assert result.exit_code == 0
1475 parsed = _parse_push(result.output)
1476 assert parsed["release_id"] == rec.release_id
1477
1478 def test_push_dry_run_remote_default_is_origin(self, tmp_path: pathlib.Path) -> None:
1479 """--remote defaults to 'origin'."""
1480 root, repo_id = _init_repo(tmp_path)
1481 _write_release(root, repo_id, "v1.0.0")
1482 result = _invoke(["release", "push", "v1.0.0", "--dry-run", "--json"], root)
1483 assert result.exit_code == 0
1484 parsed = _parse_push(result.output)
1485 assert parsed["remote"] == "origin"
1486
1487 def test_push_dry_run_custom_remote_in_json(self, tmp_path: pathlib.Path) -> None:
1488 """Custom --remote name is reflected in JSON output."""
1489 root, repo_id = _init_repo(tmp_path)
1490 _write_release(root, repo_id, "v1.0.0")
1491 result = _invoke(
1492 ["release", "push", "v1.0.0", "--remote", "staging", "--dry-run", "--json"], root
1493 )
1494 assert result.exit_code == 0
1495 parsed = _parse_push(result.output)
1496 assert parsed["remote"] == "staging"
1497
1498 def test_push_not_found_exits_4(self, tmp_path: pathlib.Path) -> None:
1499 """Missing local release exits with code 4."""
1500 root, _ = _init_repo(tmp_path)
1501 result = _invoke(["release", "push", "v99.0.0", "--remote", "origin"], root)
1502 assert result.exit_code == 4
1503
1504 def test_push_not_found_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
1505 """'not found' message goes to stderr, stdout is empty."""
1506 root, _ = _init_repo(tmp_path)
1507 result = _invoke(["release", "push", "v99.0.0", "--remote", "origin"], root)
1508 assert result.exit_code != 0
1509 assert "not found" in result.stderr.lower()
1510
1511 def test_push_dry_run_no_transport_call(self, tmp_path: pathlib.Path) -> None:
1512 """--dry-run must not invoke transport.create_release."""
1513 root, repo_id = _init_repo(tmp_path)
1514 _write_release(root, repo_id, "v1.0.0")
1515 with patch("muse.cli.commands.release.make_transport") as mock_transport:
1516 result = _invoke(
1517 ["release", "push", "v1.0.0", "--remote", "origin", "--dry-run"], root
1518 )
1519 assert result.exit_code == 0
1520 mock_transport.assert_not_called()
1521
1522 def test_push_text_output_mentions_tag(self, tmp_path: pathlib.Path) -> None:
1523 """Text dry-run output contains the tag."""
1524 root, repo_id = _init_repo(tmp_path)
1525 _write_release(root, repo_id, "v2.3.4")
1526 result = _invoke(
1527 ["release", "push", "v2.3.4", "--remote", "origin", "--dry-run"], root
1528 )
1529 assert result.exit_code == 0
1530 assert "v2.3.4" in result.output
1531
1532 def test_push_text_output_mentions_remote(self, tmp_path: pathlib.Path) -> None:
1533 """Text dry-run output mentions the remote."""
1534 root, repo_id = _init_repo(tmp_path)
1535 _write_release(root, repo_id, "v1.0.0")
1536 result = _invoke(
1537 ["release", "push", "v1.0.0", "--remote", "myremote", "--dry-run"], root
1538 )
1539 assert result.exit_code == 0
1540 assert "myremote" in result.output
1541
1542 def test_push_remote_not_configured_exits_1(self, tmp_path: pathlib.Path) -> None:
1543 """Missing remote config exits with code 1."""
1544 root, repo_id = _init_repo(tmp_path)
1545 _write_release(root, repo_id, "v1.0.0")
1546 result = _invoke(["release", "push", "v1.0.0", "--remote", "nonexistent"], root)
1547 assert result.exit_code == 1
1548
1549 def test_push_remote_error_exits_5(self, tmp_path: pathlib.Path) -> None:
1550 """TransportError from create_release exits with code 5."""
1551 from muse.core.transport import TransportError
1552
1553 root, repo_id = _init_repo(tmp_path)
1554 _write_release(root, repo_id, "v1.0.0")
1555 mock_t = MagicMock()
1556 mock_t.create_release.side_effect = TransportError("server error", 500)
1557 with patch("muse.cli.commands.release.make_transport", return_value=mock_t):
1558 with patch("muse.cli.commands.release.get_signing_identity", return_value="tok"):
1559 with patch("muse.cli.commands.release._resolve_remote_url", return_value="http://hub"):
1560 result = _invoke(["release", "push", "v1.0.0", "--remote", "origin"], root)
1561 assert result.exit_code == 5
1562
1563 def test_push_remote_error_message_to_stderr(self, tmp_path: pathlib.Path) -> None:
1564 """Transport error message appears in output."""
1565 from muse.core.transport import TransportError
1566
1567 root, repo_id = _init_repo(tmp_path)
1568 _write_release(root, repo_id, "v1.0.0")
1569 mock_t = MagicMock()
1570 mock_t.create_release.side_effect = TransportError("timeout reached", 0)
1571 with patch("muse.cli.commands.release.make_transport", return_value=mock_t):
1572 with patch("muse.cli.commands.release.get_signing_identity", return_value="tok"):
1573 with patch("muse.cli.commands.release._resolve_remote_url", return_value="http://hub"):
1574 result = _invoke(["release", "push", "v1.0.0", "--remote", "origin"], root)
1575 assert result.exit_code == 5
1576 assert "push failed" in result.stderr.lower()
1577
1578 def test_push_success_json_schema(self, tmp_path: pathlib.Path) -> None:
1579 """Successful push JSON has all required fields."""
1580 remote_id = fake_id("remote-release")
1581 root, repo_id = _init_repo(tmp_path)
1582 _write_release(root, repo_id, "v1.0.0")
1583 mock_t = MagicMock()
1584 mock_t.create_release.return_value = remote_id
1585 with patch("muse.cli.commands.release.make_transport", return_value=mock_t):
1586 with patch("muse.cli.commands.release.get_signing_identity", return_value="tok"):
1587 with patch("muse.cli.commands.release._resolve_remote_url", return_value="http://hub"):
1588 result = _invoke(
1589 ["release", "push", "v1.0.0", "--remote", "origin", "--json"], root
1590 )
1591 assert result.exit_code == 0
1592 parsed = _parse_push(result.output)
1593 assert parsed["status"] == "pushed"
1594 assert parsed["tag"] == "v1.0.0"
1595 assert parsed["remote"] == "origin"
1596 assert parsed["release_id"] == remote_id
1597 assert parsed["dry_run"] is False
1598
1599 def test_push_success_text_output(self, tmp_path: pathlib.Path) -> None:
1600 """Successful push text output mentions tag and remote."""
1601 root, repo_id = _init_repo(tmp_path)
1602 _write_release(root, repo_id, "v1.2.3")
1603 mock_t = MagicMock()
1604 mock_t.create_release.return_value = fake_id("remote-release-123")
1605 with patch("muse.cli.commands.release.make_transport", return_value=mock_t):
1606 with patch("muse.cli.commands.release.get_signing_identity", return_value="tok"):
1607 with patch("muse.cli.commands.release._resolve_remote_url", return_value="http://hub"):
1608 result = _invoke(["release", "push", "v1.2.3", "--remote", "origin"], root)
1609 assert result.exit_code == 0
1610 assert "v1.2.3" in result.output
1611 assert "origin" in result.output
1612
1613 def test_push_dry_run_tag_in_json(self, tmp_path: pathlib.Path) -> None:
1614 """dry-run JSON tag field matches the requested tag."""
1615 root, repo_id = _init_repo(tmp_path)
1616 _write_release(root, repo_id, "v3.1.4")
1617 result = _invoke(
1618 ["release", "push", "v3.1.4", "--remote", "origin", "--dry-run", "--json"], root
1619 )
1620 assert result.exit_code == 0
1621 assert json.loads(_json_blob(result.output))["tag"] == "v3.1.4"
1622
1623
1624 class TestReleasePushSecurity:
1625 """Security tests for ``muse release push``."""
1626
1627 def test_push_ansi_tag_stripped_in_dry_run_text(self, tmp_path: pathlib.Path) -> None:
1628 """ANSI escape in tag is stripped from dry-run text output."""
1629 malicious_tag = "\x1b[31mv1.0.0\x1b[0m"
1630 root, repo_id = _init_repo(tmp_path)
1631 rec = ReleaseRecord(
1632 repo_id=repo_id,
1633 release_id=fake_id("release"),
1634 tag=malicious_tag,
1635 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
1636 channel="stable",
1637 commit_id="a" * 64,
1638 snapshot_id="b" * 64,
1639 title="malicious",
1640 body="",
1641 changelog=[],
1642 is_draft=False,
1643 )
1644 write_release(root, rec)
1645 result = _invoke(
1646 ["release", "push", malicious_tag, "--remote", "origin", "--dry-run"], root
1647 )
1648 assert result.exit_code == 0
1649 assert "\x1b[31m" not in result.output
1650
1651 def test_push_ansi_remote_stripped_in_dry_run_text(self, tmp_path: pathlib.Path) -> None:
1652 """ANSI escape in remote name is stripped from dry-run text output."""
1653 root, repo_id = _init_repo(tmp_path)
1654 _write_release(root, repo_id, "v1.0.0")
1655 # Remote with ANSI — will hit "remote not configured" path but still
1656 # sanitize_display must strip control chars from error message output.
1657 malicious_remote = "\x1b[32morigin\x1b[0m"
1658 result = _invoke(
1659 ["release", "push", "v1.0.0", "--remote", malicious_remote, "--dry-run"], root
1660 )
1661 # dry-run skips remote lookup; the remote name appears in output
1662 assert result.exit_code == 0
1663 assert "\x1b[32m" not in result.output
1664
1665 def test_push_control_char_tag_stripped_in_text(self, tmp_path: pathlib.Path) -> None:
1666 """Control characters in tag are stripped from dry-run text output."""
1667 malicious_tag = "v1.0.0\r\ninjected"
1668 root, repo_id = _init_repo(tmp_path)
1669 rec = ReleaseRecord(
1670 repo_id=repo_id,
1671 release_id=fake_id("release"),
1672 tag=malicious_tag,
1673 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
1674 channel="stable",
1675 commit_id="a" * 64,
1676 snapshot_id="b" * 64,
1677 title="ctrl",
1678 body="",
1679 changelog=[],
1680 is_draft=False,
1681 )
1682 write_release(root, rec)
1683 result = _invoke(
1684 ["release", "push", malicious_tag, "--remote", "origin", "--dry-run"], root
1685 )
1686 assert result.exit_code == 0
1687 assert "\r" not in result.output
1688
1689 def test_push_ansi_tag_preserved_in_json(self, tmp_path: pathlib.Path) -> None:
1690 """ANSI in tag is NOT stripped from JSON output (raw data for agents)."""
1691 malicious_tag = "\x1b[31mv1.0.0\x1b[0m"
1692 root, repo_id = _init_repo(tmp_path)
1693 rec = ReleaseRecord(
1694 repo_id=repo_id,
1695 release_id=fake_id("release"),
1696 tag=malicious_tag,
1697 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
1698 channel="stable",
1699 commit_id="a" * 64,
1700 snapshot_id="b" * 64,
1701 title="malicious",
1702 body="",
1703 changelog=[],
1704 is_draft=False,
1705 )
1706 write_release(root, rec)
1707 result = _invoke(
1708 ["release", "push", malicious_tag, "--remote", "origin", "--dry-run", "--json"], root
1709 )
1710 assert result.exit_code == 0
1711 data = json.loads(_json_blob(result.output))
1712 # JSON carries raw tag; sanitization only applies to human-readable text
1713 assert data["tag"] == malicious_tag
1714
1715 def test_push_remote_error_ansi_stripped(self, tmp_path: pathlib.Path) -> None:
1716 """ANSI in TransportError message is stripped from error output."""
1717 from muse.core.transport import TransportError
1718
1719 root, repo_id = _init_repo(tmp_path)
1720 _write_release(root, repo_id, "v1.0.0")
1721 mock_t = MagicMock()
1722 mock_t.create_release.side_effect = TransportError("\x1b[31mfailed\x1b[0m", 503)
1723 with patch("muse.cli.commands.release.make_transport", return_value=mock_t):
1724 with patch("muse.cli.commands.release.get_signing_identity", return_value="tok"):
1725 with patch("muse.cli.commands.release._resolve_remote_url", return_value="http://hub"):
1726 result = _invoke(["release", "push", "v1.0.0", "--remote", "origin"], root)
1727 assert result.exit_code == 5
1728 assert "\x1b[31m" not in result.output
1729
1730 def test_push_not_found_message_sanitized(self, tmp_path: pathlib.Path) -> None:
1731 """ANSI in 'not found' tag path is stripped from error message."""
1732 root, _ = _init_repo(tmp_path)
1733 malicious_tag = "\x1b[31mv99.0.0\x1b[0m"
1734 result = _invoke(["release", "push", malicious_tag, "--remote", "origin"], root)
1735 assert result.exit_code != 0
1736 assert "\x1b[31m" not in result.output
1737
1738
1739 class TestReleasePushStress:
1740 """Stress tests for ``muse release push``."""
1741
1742 def test_push_dry_run_50_different_tags(self, tmp_path: pathlib.Path) -> None:
1743 """50 different tags each dry-run push successfully."""
1744 root, repo_id = _init_repo(tmp_path)
1745 for i in range(50):
1746 _write_release(root, repo_id, f"v1.{i}.0")
1747 for i in range(50):
1748 r = _invoke(
1749 ["release", "push", f"v1.{i}.0", "--remote", "origin", "--dry-run", "--json"],
1750 root,
1751 )
1752 assert r.exit_code == 0, f"v1.{i}.0 failed: {r.output}"
1753 assert json.loads(_json_blob(r.output))["status"] == "dry_run"
1754
1755 def test_push_concurrent_dry_run_reads(self, tmp_path: pathlib.Path) -> None:
1756 """Concurrent get_release_for_tag calls (push lookup path) must not crash."""
1757 root, repo_id = _init_repo(tmp_path)
1758 for i in range(20):
1759 _write_release(root, repo_id, f"v2.{i}.0")
1760 errors: list[str] = []
1761
1762 def _do_lookup(tag: str) -> None:
1763 try:
1764 rec = get_release_for_tag(root, repo_id, tag)
1765 assert rec is not None
1766 assert rec.tag == tag
1767 except Exception as exc: # noqa: BLE001
1768 errors.append(str(exc))
1769
1770 threads = [threading.Thread(target=_do_lookup, args=(f"v2.{i}.0",)) for i in range(20)]
1771 for t in threads:
1772 t.start()
1773 for t in threads:
1774 t.join()
1775 assert not errors, f"Concurrent failures: {errors}"
1776
1777 def test_push_dry_run_json_compact_no_indent(self, tmp_path: pathlib.Path) -> None:
1778 """JSON output is compact (no indentation), consistent with other commands."""
1779 root, repo_id = _init_repo(tmp_path)
1780 _write_release(root, repo_id, "v1.0.0")
1781 result = _invoke(
1782 ["release", "push", "v1.0.0", "--remote", "origin", "--dry-run", "--json"], root
1783 )
1784 assert result.exit_code == 0
1785 raw = _json_blob(result.output)
1786 # Compact JSON has no leading spaces on keys
1787 assert "\n " not in raw
1788
1789
1790 # ---------------------------------------------------------------------------
1791 # Extended / Security / Stress tests for ``muse release delete``
1792 # ---------------------------------------------------------------------------
1793
1794
1795 class TestReleaseDeleteExtended:
1796 """Unit, integration, and edge-case tests for ``muse release delete``."""
1797
1798 def test_delete_help_contains_agent_quickstart(self) -> None:
1799 result = runner.invoke(None, ["release", "delete", "--help"])
1800 assert result.exit_code == 0
1801 assert "quickstart" in result.output.lower() or "muse release delete v" in result.output
1802
1803 def test_delete_help_contains_json_schema(self) -> None:
1804 result = runner.invoke(None, ["release", "delete", "--help"])
1805 assert result.exit_code == 0
1806 assert "was_draft" in result.output
1807
1808 def test_delete_help_contains_exit_codes(self) -> None:
1809 result = runner.invoke(None, ["release", "delete", "--help"])
1810 assert result.exit_code == 0
1811 assert "exit code" in result.output.lower() or "0 —" in result.output
1812
1813 def test_delete_j_alias_dry_run(self, tmp_path: pathlib.Path) -> None:
1814 """-j is an alias for --json."""
1815 root, repo_id = _init_repo(tmp_path)
1816 _write_release(root, repo_id, "v1.0.0")
1817 result = _invoke(["release", "delete", "v1.0.0", "--dry-run", "-j"], root)
1818 assert result.exit_code == 0
1819 parsed = _parse_delete(result.output)
1820 assert parsed["status"] == "dry_run"
1821 assert parsed["dry_run"] is True
1822
1823 def test_delete_not_found_exits_4(self, tmp_path: pathlib.Path) -> None:
1824 """Missing local tag exits code 4."""
1825 root, _ = _init_repo(tmp_path)
1826 result = _invoke(["release", "delete", "v99.0.0", "--yes"], root)
1827 assert result.exit_code == 4
1828
1829 def test_delete_yes_skips_confirmation(self, tmp_path: pathlib.Path) -> None:
1830 """--yes deletes without prompting in non-TTY context."""
1831 root, repo_id = _init_repo(tmp_path)
1832 _write_release(root, repo_id, "v1.0.0")
1833 result = _invoke(["release", "delete", "v1.0.0", "--yes"], root)
1834 assert result.exit_code == 0
1835 assert get_release_for_tag(root, repo_id, "v1.0.0") is None
1836
1837 def test_delete_yes_json_was_draft_false(self, tmp_path: pathlib.Path) -> None:
1838 """JSON was_draft reflects false for a published release."""
1839 root, repo_id = _init_repo(tmp_path)
1840 _write_release(root, repo_id, "v1.0.0", is_draft=False)
1841 result = _invoke(["release", "delete", "v1.0.0", "--yes", "--json"], root)
1842 assert result.exit_code == 0
1843 parsed = _parse_delete(result.output)
1844 assert parsed["was_draft"] is False
1845
1846 def test_delete_yes_json_was_draft_true(self, tmp_path: pathlib.Path) -> None:
1847 """JSON was_draft reflects true for a draft release."""
1848 root, repo_id = _init_repo(tmp_path)
1849 _write_release(root, repo_id, "v1.0.0-alpha.1", is_draft=True)
1850 result = _invoke(["release", "delete", "v1.0.0-alpha.1", "--yes", "--json"], root)
1851 assert result.exit_code == 0
1852 parsed = _parse_delete(result.output)
1853 assert parsed["was_draft"] is True
1854
1855 def test_delete_dry_run_preserves_release(self, tmp_path: pathlib.Path) -> None:
1856 """--dry-run must not remove the release record."""
1857 root, repo_id = _init_repo(tmp_path)
1858 _write_release(root, repo_id, "v1.0.0")
1859 result = _invoke(["release", "delete", "v1.0.0", "--dry-run"], root)
1860 assert result.exit_code == 0
1861 assert get_release_for_tag(root, repo_id, "v1.0.0") is not None
1862
1863 def test_delete_dry_run_json_remote_retracted_false(self, tmp_path: pathlib.Path) -> None:
1864 """dry-run JSON always has remote_retracted=false."""
1865 root, repo_id = _init_repo(tmp_path)
1866 _write_release(root, repo_id, "v1.0.0")
1867 result = _invoke(
1868 ["release", "delete", "v1.0.0", "--dry-run", "--remote", "origin", "--json"], root
1869 )
1870 assert result.exit_code == 0
1871 parsed = _parse_delete(result.output)
1872 assert parsed["remote_retracted"] is False
1873 assert parsed["dry_run"] is True
1874
1875 def test_delete_dry_run_text_mentions_tag(self, tmp_path: pathlib.Path) -> None:
1876 """dry-run text output contains the tag."""
1877 root, repo_id = _init_repo(tmp_path)
1878 _write_release(root, repo_id, "v2.3.4")
1879 result = _invoke(["release", "delete", "v2.3.4", "--dry-run"], root)
1880 assert result.exit_code == 0
1881 assert "v2.3.4" in result.output
1882
1883 def test_delete_dry_run_text_mentions_remote(self, tmp_path: pathlib.Path) -> None:
1884 """dry-run text output mentions the remote when --remote is supplied."""
1885 root, repo_id = _init_repo(tmp_path)
1886 _write_release(root, repo_id, "v1.0.0")
1887 result = _invoke(
1888 ["release", "delete", "v1.0.0", "--dry-run", "--remote", "staging"], root
1889 )
1890 assert result.exit_code == 0
1891 assert "staging" in result.output
1892
1893 def test_delete_remote_error_exits_5(self, tmp_path: pathlib.Path) -> None:
1894 """TransportError from delete_release_remote exits with code 5."""
1895 from muse.core.transport import TransportError
1896
1897 root, repo_id = _init_repo(tmp_path)
1898 _write_release(root, repo_id, "v1.0.0")
1899 mock_t = MagicMock()
1900 mock_t.delete_release_remote.side_effect = TransportError("gone", 404)
1901 with patch("muse.cli.commands.release.make_transport", return_value=mock_t):
1902 with patch("muse.cli.commands.release.get_signing_identity", return_value="tok"):
1903 with patch("muse.cli.commands.release._resolve_remote_url", return_value="http://hub"):
1904 result = _invoke(
1905 ["release", "delete", "v1.0.0", "--yes", "--remote", "origin"], root
1906 )
1907 assert result.exit_code == 5
1908
1909 def test_delete_remote_success_sets_remote_retracted(self, tmp_path: pathlib.Path) -> None:
1910 """Successful remote retraction sets remote_retracted=true in JSON."""
1911 root, repo_id = _init_repo(tmp_path)
1912 _write_release(root, repo_id, "v1.0.0")
1913 mock_t = MagicMock()
1914 mock_t.delete_release_remote.return_value = None
1915 with patch("muse.cli.commands.release.make_transport", return_value=mock_t):
1916 with patch("muse.cli.commands.release.get_signing_identity", return_value="tok"):
1917 with patch("muse.cli.commands.release._resolve_remote_url", return_value="http://hub"):
1918 result = _invoke(
1919 ["release", "delete", "v1.0.0", "--yes", "--remote", "origin", "--json"],
1920 root,
1921 )
1922 assert result.exit_code == 0
1923 parsed = _parse_delete(result.output)
1924 assert parsed["status"] == "deleted"
1925 assert parsed["remote_retracted"] is True
1926
1927 def test_delete_remote_not_configured_exits_1(self, tmp_path: pathlib.Path) -> None:
1928 """Unconfigured --remote exits with code 1."""
1929 root, repo_id = _init_repo(tmp_path)
1930 _write_release(root, repo_id, "v1.0.0")
1931 result = _invoke(
1932 ["release", "delete", "v1.0.0", "--yes", "--remote", "nonexistent"], root
1933 )
1934 assert result.exit_code == 1
1935
1936 def test_delete_json_compact_no_indent(self, tmp_path: pathlib.Path) -> None:
1937 """JSON output is compact (no indentation)."""
1938 root, repo_id = _init_repo(tmp_path)
1939 _write_release(root, repo_id, "v1.0.0")
1940 result = _invoke(["release", "delete", "v1.0.0", "--dry-run", "--json"], root)
1941 assert result.exit_code == 0
1942 raw = _json_blob(result.output)
1943 assert "\n " not in raw
1944
1945 def test_delete_success_text_mentions_deleted(self, tmp_path: pathlib.Path) -> None:
1946 """Success text output says 'deleted'."""
1947 root, repo_id = _init_repo(tmp_path)
1948 _write_release(root, repo_id, "v1.0.0")
1949 result = _invoke(["release", "delete", "v1.0.0", "--yes"], root)
1950 assert result.exit_code == 0
1951 assert "deleted" in result.output.lower()
1952
1953 def test_delete_non_tty_without_yes_exits_1(self, tmp_path: pathlib.Path) -> None:
1954 """Non-TTY delete without --yes exits USER_ERROR (1), never blocks."""
1955 root, repo_id = _init_repo(tmp_path)
1956 _write_release(root, repo_id, "v1.0.0")
1957 # CliRunner runs without a TTY by default.
1958 result = _invoke(["release", "delete", "v1.0.0"], root)
1959 assert result.exit_code == 1
1960
1961
1962 class TestReleaseDeleteSecurity:
1963 """Security tests for ``muse release delete``."""
1964
1965 def test_delete_ansi_tag_stripped_in_dry_run_text(self, tmp_path: pathlib.Path) -> None:
1966 """ANSI escape in tag is stripped from dry-run text output."""
1967 malicious_tag = "\x1b[31mv1.0.0\x1b[0m"
1968 root, repo_id = _init_repo(tmp_path)
1969 rec = ReleaseRecord(
1970 repo_id=repo_id,
1971 release_id=fake_id("release"),
1972 tag=malicious_tag,
1973 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
1974 channel="stable",
1975 commit_id="a" * 64,
1976 snapshot_id="b" * 64,
1977 title="malicious",
1978 body="",
1979 changelog=[],
1980 is_draft=False,
1981 )
1982 write_release(root, rec)
1983 result = _invoke(["release", "delete", malicious_tag, "--dry-run"], root)
1984 assert result.exit_code == 0
1985 assert "\x1b[31m" not in result.output
1986
1987 def test_delete_ansi_tag_stripped_in_success_text(self, tmp_path: pathlib.Path) -> None:
1988 """ANSI escape in tag is stripped from delete success text output."""
1989 malicious_tag = "\x1b[32mv1.0.0\x1b[0m"
1990 root, repo_id = _init_repo(tmp_path)
1991 rec = ReleaseRecord(
1992 repo_id=repo_id,
1993 release_id=fake_id("release"),
1994 tag=malicious_tag,
1995 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
1996 channel="stable",
1997 commit_id="a" * 64,
1998 snapshot_id="b" * 64,
1999 title="malicious",
2000 body="",
2001 changelog=[],
2002 is_draft=False,
2003 )
2004 write_release(root, rec)
2005 result = _invoke(["release", "delete", malicious_tag, "--yes"], root)
2006 assert result.exit_code == 0
2007 assert "\x1b[32m" not in result.output
2008
2009 def test_delete_control_char_tag_stripped_in_dry_run(self, tmp_path: pathlib.Path) -> None:
2010 """Control characters in tag are stripped from dry-run text output."""
2011 malicious_tag = "v1.0.0\r\ninjected"
2012 root, repo_id = _init_repo(tmp_path)
2013 rec = ReleaseRecord(
2014 repo_id=repo_id,
2015 release_id=fake_id("release"),
2016 tag=malicious_tag,
2017 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
2018 channel="stable",
2019 commit_id="a" * 64,
2020 snapshot_id="b" * 64,
2021 title="ctrl",
2022 body="",
2023 changelog=[],
2024 is_draft=False,
2025 )
2026 write_release(root, rec)
2027 result = _invoke(["release", "delete", malicious_tag, "--dry-run"], root)
2028 assert result.exit_code == 0
2029 assert "\r" not in result.output
2030
2031 def test_delete_ansi_tag_preserved_in_json(self, tmp_path: pathlib.Path) -> None:
2032 """ANSI in tag is NOT stripped from JSON output (raw data for agents)."""
2033 malicious_tag = "\x1b[31mv1.0.0\x1b[0m"
2034 root, repo_id = _init_repo(tmp_path)
2035 rec = ReleaseRecord(
2036 repo_id=repo_id,
2037 release_id=fake_id("release"),
2038 tag=malicious_tag,
2039 semver=SemVerTag(major=1, minor=0, patch=0, pre="", build=""),
2040 channel="stable",
2041 commit_id="a" * 64,
2042 snapshot_id="b" * 64,
2043 title="malicious",
2044 body="",
2045 changelog=[],
2046 is_draft=False,
2047 )
2048 write_release(root, rec)
2049 result = _invoke(["release", "delete", malicious_tag, "--dry-run", "--json"], root)
2050 assert result.exit_code == 0
2051 data = json.loads(_json_blob(result.output))
2052 # JSON carries raw tag; sanitization only applies to human-readable text
2053 assert data["tag"] == malicious_tag
2054
2055 def test_delete_remote_error_ansi_stripped(self, tmp_path: pathlib.Path) -> None:
2056 """ANSI in TransportError message is stripped from error output."""
2057 from muse.core.transport import TransportError
2058
2059 root, repo_id = _init_repo(tmp_path)
2060 _write_release(root, repo_id, "v1.0.0")
2061 mock_t = MagicMock()
2062 mock_t.delete_release_remote.side_effect = TransportError("\x1b[31mfailed\x1b[0m", 503)
2063 with patch("muse.cli.commands.release.make_transport", return_value=mock_t):
2064 with patch("muse.cli.commands.release.get_signing_identity", return_value="tok"):
2065 with patch("muse.cli.commands.release._resolve_remote_url", return_value="http://hub"):
2066 result = _invoke(
2067 ["release", "delete", "v1.0.0", "--yes", "--remote", "origin"], root
2068 )
2069 assert result.exit_code == 5
2070 assert "\x1b[31m" not in result.output
2071
2072 def test_delete_not_found_message_sanitized(self, tmp_path: pathlib.Path) -> None:
2073 """ANSI in tag is stripped from 'not found' error message."""
2074 root, _ = _init_repo(tmp_path)
2075 malicious_tag = "\x1b[31mv99.0.0\x1b[0m"
2076 result = _invoke(["release", "delete", malicious_tag, "--yes"], root)
2077 assert result.exit_code != 0
2078 assert "\x1b[31m" not in result.output
2079
2080
2081 class TestReleaseDeleteStress:
2082 """Stress tests for ``muse release delete``."""
2083
2084 def test_delete_50_releases_sequential(self, tmp_path: pathlib.Path) -> None:
2085 """Add 50 releases then delete all; list must be empty."""
2086 root, repo_id = _init_repo(tmp_path)
2087 for i in range(50):
2088 _write_release(root, repo_id, f"v1.{i}.0")
2089 assert len(list_releases(root, repo_id)) == 50
2090 for i in range(50):
2091 r = _invoke(["release", "delete", f"v1.{i}.0", "--yes", "--json"], root)
2092 assert r.exit_code == 0, f"v1.{i}.0 failed: {r.output}"
2093 assert json.loads(_json_blob(r.output))["status"] == "deleted"
2094 assert list_releases(root, repo_id) == []
2095
2096 def test_delete_concurrent_different_tags(self, tmp_path: pathlib.Path) -> None:
2097 """Concurrent delete_release calls on distinct tags must not crash or corrupt."""
2098 root, repo_id = _init_repo(tmp_path)
2099 recs = [_write_release(root, repo_id, f"v3.{i}.0") for i in range(20)]
2100 errors: list[str] = []
2101
2102 def _do_delete(rec_id: str) -> None:
2103 try:
2104 result = delete_release(root, repo_id, rec_id)
2105 assert result is True
2106 except Exception as exc: # noqa: BLE001
2107 errors.append(str(exc))
2108
2109 threads = [threading.Thread(target=_do_delete, args=(r.release_id,)) for r in recs]
2110 for t in threads:
2111 t.start()
2112 for t in threads:
2113 t.join()
2114 assert not errors, f"Concurrent failures: {errors}"
2115 assert list_releases(root, repo_id) == []
2116
2117 def test_delete_dry_run_json_compact_50(self, tmp_path: pathlib.Path) -> None:
2118 """50 dry-run delete JSON outputs are all compact (no indent)."""
2119 root, repo_id = _init_repo(tmp_path)
2120 for i in range(50):
2121 _write_release(root, repo_id, f"v4.{i}.0")
2122 for i in range(50):
2123 r = _invoke(
2124 ["release", "delete", f"v4.{i}.0", "--dry-run", "--json"], root
2125 )
2126 assert r.exit_code == 0, f"v4.{i}.0 failed: {r.output}"
2127 raw = _json_blob(r.output)
2128 assert "\n " not in raw
File History 1 commit