650 lines
27 KiB
Python
650 lines
27 KiB
Python
"""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
|