"""Verification, resolution, and validation tests for `specify self upgrade`.""" import urllib.error from unittest.mock import patch import pytest import specify_cli from specify_cli import app from tests.self_upgrade_helpers import ( SENTINEL_GH_TOKEN, SENTINEL_GITHUB_TOKEN, _InstallMethod, _UpgradePlan, _completed_process, _verify_upgrade, mock_urlopen_response, runner, strip_ansi, ) # =========================================================================== # Phase 6 — User Story 4: failure recovery (P2) # =========================================================================== class TestVerificationMismatch: """Installer says 0 but the binary is still the old version → exit 2.""" def test_installer_ok_but_verify_returns_old_version( self, uv_tool_argv0, clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), # installer OK _completed_process(0, stdout="specify 0.7.5\n"), # verify: OLD! ] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 2 out = strip_ansi(result.output) assert "Verification failed" in out assert "resolves to 0.7.5 (expected v0.7.6)" in out assert "The new version may take effect on your next invocation." in out def test_verify_nonzero_exit_is_not_treated_as_success( self, uv_tool_argv0, clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(1, stdout="specify 0.7.6\n"), ] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 2 out = strip_ansi(result.output) assert "Verification failed" in out assert "(unknown) (expected v0.7.6)" in out def test_verify_accepts_pep440_equivalent_rc_version( self, uv_tool_argv0, clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.9.0" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 1.0.0rc1\n"), ] result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"]) assert result.exit_code == 0 assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output) def test_verify_accepts_specify_cli_binary_name_in_version_output( self, uv_tool_argv0, clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify-cli version 0.7.6\n"), ] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output) def test_verify_accepts_capitalized_binary_name_in_version_output( self, uv_tool_argv0, clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="Specify, version 0.7.6\n"), ] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output) def test_verify_rejects_output_without_parseable_version( self, uv_tool_argv0, clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify version unknown\n"), ] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 2 out = strip_ansi(result.output) assert "Verification failed" in out assert "(unknown) (expected v0.7.6)" in out def test_verify_uses_current_entrypoint_when_not_on_path( self, uv_tool_argv0, clean_environ, ): assert uv_tool_argv0.exists() assert uv_tool_argv0.is_file() plan = _UpgradePlan( method=_InstallMethod.UV_TOOL, current_version="0.7.5", target_tag="v0.7.6", installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], preview_summary="", pre_upgrade_snapshot="0.7.5", ) with patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version.os.access", return_value=True ): mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") verified = _verify_upgrade(plan) assert verified == "0.7.6" assert mock_run.call_args.args[0][0] == str(uv_tool_argv0) assert mock_run.call_args.kwargs["timeout"] == specify_cli._version._VERIFY_TIMEOUT_SECS def test_verify_falls_back_to_path_when_current_entrypoint_is_not_executable( self, uv_tool_argv0, clean_environ, ): plan = _UpgradePlan( method=_InstallMethod.UV_TOOL, current_version="0.7.5", target_tag="v0.7.6", installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], preview_summary="", pre_upgrade_snapshot="0.7.5", ) with patch( "specify_cli._version.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None, ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version.os.access", return_value=False ): mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") verified = _verify_upgrade(plan) assert verified == "0.7.6" assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify" def test_verify_ignores_python_entrypoint_and_falls_back_to_specify( self, clean_environ, tmp_path, ): fake_python = tmp_path / "python3" fake_python.write_text("#!/bin/sh\n") fake_python.chmod(0o755) plan = _UpgradePlan( method=_InstallMethod.UV_TOOL, current_version="0.7.5", target_tag="v0.7.6", installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], preview_summary="", pre_upgrade_snapshot="0.7.5", ) with patch( "specify_cli._version.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version.sys.argv", [str(fake_python)] ), patch( "specify_cli._version.os.access", return_value=True ): mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") verified = _verify_upgrade(plan) assert verified == "0.7.6" assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify" def test_verify_accepts_specify_cli_named_current_entrypoint( self, clean_environ, tmp_path, ): fake_specify_cli = tmp_path / "specify-cli" fake_specify_cli.write_text("#!/bin/sh\n") fake_specify_cli.chmod(0o755) plan = _UpgradePlan( method=_InstallMethod.UV_TOOL, current_version="0.7.5", target_tag="v0.7.6", installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"], preview_summary="", pre_upgrade_snapshot="0.7.5", ) with patch("specify_cli._version.shutil.which", return_value=None), patch( "specify_cli._version.subprocess.run" ) as mock_run, patch("specify_cli._version.sys.argv", [str(fake_specify_cli)]), patch( "specify_cli._version.os.access", return_value=True ): mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n") verified = _verify_upgrade(plan) assert verified == "0.7.6" assert mock_run.call_args.args[0][0] == str(fake_specify_cli) class TestResolutionFailures: """Pre-installer resolution failure → exit 1, reusing the resolver category strings.""" def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ): with patch( "specify_cli.authentication.http.urllib.request.urlopen", side_effect=urllib.error.URLError("nope"), ): result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 1 assert "Upgrade aborted: offline or timeout" in strip_ansi(result.output) def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ): err = urllib.error.HTTPError( url="https://api.github.com", code=403, msg="rate limited", hdrs={}, # type: ignore[arg-type] fp=None, ) with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err): result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 1 assert ( "Upgrade aborted: rate limited (configure ~/.specify/auth.json with a GitHub token)" in strip_ansi(result.output) ) def test_http_500_exits_1(self, uv_tool_argv0, clean_environ): err = urllib.error.HTTPError( url="https://api.github.com", code=500, msg="srv err", hdrs={}, # type: ignore[arg-type] fp=None, ) with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err): result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 1 assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output) @pytest.mark.parametrize( "code, expected", [ # 429 (Too Many Requests / secondary rate limit) gets the same # actionable token hint as 403; other statuses surface verbatim. ( 429, "Upgrade aborted: rate limited (configure ~/.specify/auth.json " "with a GitHub token)", ), (404, "Upgrade aborted: HTTP 404"), (502, "Upgrade aborted: HTTP 502"), ], ) def test_http_error_categorization( self, code, expected, uv_tool_argv0, clean_environ ): err = urllib.error.HTTPError( url="https://api.github.com", code=code, msg="err", hdrs={}, # type: ignore[arg-type] fp=None, ) with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err): result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 1 assert expected in strip_ansi(result.output) def test_unparseable_resolved_release_tag_exits_1_without_traceback( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 1 out = strip_ansi(result.output) assert "resolved release tag is not a comparable version" in out assert "release-main" not in out assert "Traceback" not in out assert mock_run.call_count == 0 class TestTagValidation: """--tag regex enforcement.""" def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( app, ["self", "upgrade", "--dry-run", "--tag", "v0.7.6"], ) assert result.exit_code == 0 def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( app, ["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"], ) assert result.exit_code == 0 assert "Target version: v0.8.0.dev0" in strip_ansi(result.output) def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( app, ["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"], ) assert result.exit_code == 0 def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( self, uv_tool_argv0, clean_environ ): with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="1.0.0b1" ): result = runner.invoke( app, ["self", "upgrade", "--tag", "v1.0.0-beta.1"], ) assert result.exit_code == 0 assert "Already on requested release: v1.0.0-beta.1" in strip_ansi( result.output ) def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( app, ["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"], ) assert result.exit_code == 0 assert "Target version: v0.8.0+build.42" in strip_ansi(result.output) def test_uppercase_v_prefix_is_folded_to_lowercase( self, uv_tool_argv0, clean_environ ): # A pasted uppercase `V` prefix is accepted and normalized to `v` so # the git ref matches the canonical lowercase release tag. with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( app, ["self", "upgrade", "--dry-run", "--tag", "V0.7.6"], ) assert result.exit_code == 0 assert "Target version: v0.7.6" in strip_ansi(result.output) def test_valid_prerelease_with_build_metadata_tag( self, uv_tool_argv0, clean_environ ): # Prerelease and build-metadata suffixes compose (PEP 440 / semver). with patch("specify_cli._version.shutil.which", return_value="uv"), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( app, ["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1+build.42"], ) assert result.exit_code == 0 assert "Target version: v1.0.0-rc1+build.42" in strip_ansi(result.output) @pytest.mark.parametrize( "bad_tag", [ "latest", "0.7.5", "main", "v7", "", "v1.2.3abc", "v1.2.3...", "v1.2.3++", "v\uff11.2.3", "v1.\u0662.3", ], ) def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ): result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag]) assert result.exit_code == 1 output = strip_ansi(result.output) assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output class TestUnknownCurrent: """'unknown' current version renders literally in notice and success message.""" def test_unknown_current_renders_literal_in_notice( self, uv_tool_argv0, clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), ] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 out = strip_ansi(result.output) assert "Upgrading specify-cli unknown → v0.7.6 via uv tool:" in out assert "Upgraded specify-cli: unknown → 0.7.6" in out def test_unknown_current_rollback_hint_degrades( self, uv_tool_argv0, clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(2)] # installer fails result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 2 out = strip_ansi(result.output) assert "Could not determine the previous version" in out assert "https://github.com/github/spec-kit/releases" in out class TestTokenScrubbing: """GH_TOKEN / GITHUB_TOKEN are stripped from every child env.""" def test_env_passed_to_subprocess_has_no_github_tokens( self, uv_tool_argv0, monkeypatch, ): monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) response = mock_urlopen_response({"tag_name": "v0.7.6"}) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli.authentication.http.urllib.request.build_opener" ) as mock_build_opener, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = response mock_build_opener.return_value.open.return_value = response mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), ] runner.invoke(app, ["self", "upgrade"]) assert mock_run.call_count >= 1 for call in mock_run.call_args_list: env_kwarg = call.kwargs.get("env") or {} assert "GH_TOKEN" not in env_kwarg, f"env leaked GH_TOKEN: {env_kwarg!r}" assert "GITHUB_TOKEN" not in env_kwarg for v in env_kwarg.values(): assert SENTINEL_GH_TOKEN not in v assert SENTINEL_GITHUB_TOKEN not in v def test_env_scrubbing_is_case_insensitive( self, uv_tool_argv0, monkeypatch, ): monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN) monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN) response = mock_urlopen_response({"tag_name": "v0.7.6"}) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli.authentication.http.urllib.request.build_opener" ) as mock_build_opener, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = response mock_build_opener.return_value.open.return_value = response mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), ] runner.invoke(app, ["self", "upgrade"]) assert mock_run.call_count >= 1 for call in mock_run.call_args_list: env_kwarg = call.kwargs.get("env") or {} assert "gh_token" not in env_kwarg assert "GitHub_Token" not in env_kwarg for v in env_kwarg.values(): assert SENTINEL_GH_TOKEN not in v assert SENTINEL_GITHUB_TOKEN not in v def test_env_scrubbing_removes_github_token_variants(self, monkeypatch): monkeypatch.setenv("GH_PAT", "gh-pat") monkeypatch.setenv("GH_TOKEN_FILE", "gh-token-file") monkeypatch.setenv("GH_ENTERPRISE_TOKEN", "enterprise-gh") monkeypatch.setenv("GH_ENTERPRISE_SECRET", "enterprise-secret") monkeypatch.setenv("GH_ENTERPRISE_PRIVATE_KEY", "enterprise-key") monkeypatch.setenv("GITHUB_PAT", "github-pat") monkeypatch.setenv("GITHUB_TOKEN_PATH", "github-token-path") monkeypatch.setenv("GITHUB_ENTERPRISE_TOKEN", "enterprise-github") monkeypatch.setenv("GITHUB_API_TOKEN", "api-token") monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key") monkeypatch.setenv("GITHUB_OAUTH_CLIENT_SECRET", "oauth-secret") monkeypatch.setenv("HOMEBREW_GITHUB_API_TOKEN", "homebrew-token") monkeypatch.setenv("NOTGITHUB_TOKEN", "not-github-kept") monkeypatch.setenv("GHOST_API_TOKEN", "ghost-kept") monkeypatch.setenv("GHIDRA_API_KEY", "ghidra-kept") monkeypatch.setenv("UNRELATED_TOKEN", "kept") env = specify_cli._version._scrubbed_env() assert "GH_PAT" not in env assert "GH_TOKEN_FILE" not in env assert "GH_ENTERPRISE_TOKEN" not in env assert "GH_ENTERPRISE_SECRET" not in env assert "GH_ENTERPRISE_PRIVATE_KEY" not in env assert "GITHUB_PAT" not in env assert "GITHUB_TOKEN_PATH" not in env assert "GITHUB_ENTERPRISE_TOKEN" not in env assert "GITHUB_API_TOKEN" not in env assert "GITHUB_APP_PRIVATE_KEY" not in env assert "GITHUB_OAUTH_CLIENT_SECRET" not in env assert "HOMEBREW_GITHUB_API_TOKEN" not in env assert env["NOTGITHUB_TOKEN"] == "not-github-kept" assert env["GHOST_API_TOKEN"] == "ghost-kept" assert env["GHIDRA_API_KEY"] == "ghidra-kept" assert env["UNRELATED_TOKEN"] == "kept" def test_env_scrubbing_strips_noncredential_github_vars_by_design( self, monkeypatch ): # The scrub is intentionally broad: every GH_/GITHUB_-prefixed name is # removed from the installer subprocess env, including non-credential # context vars. This is a deliberate fail-safe so credential-adjacent # names that lack a recognized suffix (e.g. GH_TOKEN_FILE, # GITHUB_TOKEN_PATH, asserted above) can never leak. The installer # (`uv tool install` / `pipx install` of a public package) does not # consume routing/context vars like GITHUB_REPOSITORY, so nothing the # subprocess needs is lost by stripping them. monkeypatch.setenv("GH_HOST", "github.example.com") monkeypatch.setenv("GH_CONFIG_DIR", "/home/u/.config/gh") monkeypatch.setenv("GITHUB_REPOSITORY", "github/spec-kit") monkeypatch.setenv("GITHUB_WORKSPACE", "/home/runner/work") monkeypatch.setenv("GITHUB_USER", "octocat") env = specify_cli._version._scrubbed_env() assert "GH_HOST" not in env assert "GH_CONFIG_DIR" not in env assert "GITHUB_REPOSITORY" not in env assert "GITHUB_WORKSPACE" not in env assert "GITHUB_USER" not in env