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