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