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