mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* feat(cli): implement specify self upgrade
* fix(cli): normalize self-upgrade prerelease tags
* fix(cli): tighten self-upgrade diagnostics
* fix(cli): harden self-upgrade verification parsing
* fix(cli): sanitize self-check fallback tags
* fix(cli): harden self-check release display
* fix(cli): validate resolved upgrade tags
* fix(cli): tolerate invalid install metadata
* test(cli): align upgrade network mocks
* fix(cli): respect relative installer paths
* fix(cli): tighten upgrade failure handling
* fix(cli): align installer path diagnostics
* fix(cli): validate release and version output
* fix(cli): clarify source checkout guidance
* fix(cli): harden upgrade detection helpers
* fix(cli): avoid echoing invalid release tags
* fix(cli): tolerate argv path resolve failures
* chore: remove self-upgrade formatting-only diffs
* fix: address self-upgrade review feedback
* fix: address self-upgrade review followups
* fix: address self-upgrade review edge cases
* fix: address self-upgrade review docs
* fix: refine self-upgrade review followups
* fix: address self-upgrade review cleanup
* fix: handle self-upgrade review edge cases
* fix: address self-upgrade review nits
* fix: address follow-up self-upgrade review
* fix: resolve self-upgrade review and Windows CI failures
- README: promote "Optional Commands" to ### so it is a sibling of
"Core Commands" under "Available Slash Commands" (consistent heading
levels; avoids the h2->h4 jump a revert would create).
- _version: allow --tag prerelease/dev and build-metadata suffixes to
compose (e.g. v1.0.0-rc1+build.42), matching PEP 440 / semver; the
Version() check still enforces canonical validity.
- tests: compare resolved argv0 as Path objects instead of POSIX strings
so the assertion holds on Windows; skip the relative-installer-path
executable-bit tests on Windows via a new requires_posix marker (they
rely on chmod/X_OK semantics and chdir-into-tmp teardown that do not
hold there). Add a combined prerelease+build-metadata tag test.
* fix: address second self-upgrade review round
- self_check: clarify that the "up to date" branch is reached only for
parseable latest tags (the unparseable case returns earlier), so the
InvalidVersion fallback assumption is not reintroduced.
- self_upgrade: compare target/current as Version instances directly
instead of re-parsing the canonical strings through _is_newer; the
empty-current case stays explicit via the not-None guard.
- tests: document the intentional broad GH_/GITHUB_ env scrub with a test
asserting non-credential context vars (GH_HOST, GITHUB_REPOSITORY, …) are
stripped from the installer subprocess env — a deliberate fail-safe that
also catches credential-adjacent names without a recognized suffix.
* fix: address third self-upgrade review round
- self_upgrade: unify the no-op short-circuits on packaging Version
equality instead of canonical-string equality. Version("1.0") equals
Version("1.0.0") but their str() forms differ, so the old check could
misreport an equal install as "already on latest release or newer".
Both the unpinned and pinned branches now use Version comparison.
- self_upgrade: compare the verified version as a parsed Version against
the target so a non-version verifier result is a mismatch (exit 2)
rather than a coincidental canonical-string match.
- resolver: map HTTP 429 (Too Many Requests / secondary rate limit) to
the rate-limited category so users get the same actionable token hint
as 403.
- _is_github_credential_env_key: document the precise (intentionally
broad) scrub matching contract in the docstring.
- tests: add a trailing-zero Version-equality regression test and a
parametrized HTTP-status categorization test (429 -> rate limited;
404/502 -> verbatim).
* fix: address fourth self-upgrade review round
- self_upgrade: label a pinned target older than the installed version as
"Downgrading" rather than "Upgrading" so `--tag <older>` is not mistaken
for a forward upgrade.
- resolver: drop the unused `typing.Optional` import and annotate the
`--tag` option as `str | None`, consistent with the rest of the module
(verified Typer resolves it on the supported Python versions).
- _is_github_credential_env_key: add `_PASSWORD` and `_CREDENTIALS` to the
recognized credential suffixes and document that only these shapes are
scrubbed (not blanket coverage).
- tests: assert the precise exit code (1) for the re-raised transient
OSError path; skip the InvalidMetadataError test on Pythons where the
real exception is absent instead of fabricating it; update the pinned
downgrade test to expect the "Downgrading" label.
* fix: accept uppercase V prefix in --tag
Fold a leading uppercase `V` (a common paste) to the canonical lowercase
`v` before validating `--tag`. The remainder of the tag stays
case-sensitive on purpose: the validated value is used verbatim as a git
ref, which is case-sensitive on GitHub, so rewriting label/build-metadata
casing could point at a tag that does not exist. Adds a normalization test.
405 lines
17 KiB
Python
405 lines
17 KiB
Python
"""Tests for the `specify self` sub-app (`self check` and `self upgrade`).
|
|
|
|
Network isolation contract (SC-004 / FR-014): every test that exercises
|
|
`specify self check` or `_fetch_latest_release_tag()` MUST mock the outbound
|
|
urllib path it expects (`urlopen` for unauthenticated requests, `build_opener`
|
|
for authenticated requests) so no real outbound call ever reaches api.github.com.
|
|
Tests for non-network `self upgrade` behavior should keep that contract explicit
|
|
with local mocks. Run this module under `pytest-socket` (if installed) with
|
|
`--disable-socket` as an extra safety net.
|
|
"""
|
|
|
|
import urllib.error
|
|
import importlib.metadata
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from specify_cli import app
|
|
from specify_cli._version import (
|
|
_fetch_latest_release_tag,
|
|
_get_installed_version,
|
|
_is_newer,
|
|
_normalize_tag,
|
|
)
|
|
from tests.conftest import strip_ansi
|
|
from tests.http_helpers import mock_urlopen_response
|
|
|
|
runner = CliRunner()
|
|
|
|
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
|
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
|
|
|
|
_RATE_LIMITED_REASON = (
|
|
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
|
)
|
|
|
|
|
|
def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
|
|
return urllib.error.HTTPError(
|
|
url="https://api.github.com/repos/github/spec-kit/releases/latest",
|
|
code=code,
|
|
msg=message,
|
|
hdrs={}, # type: ignore[arg-type]
|
|
fp=None,
|
|
)
|
|
|
|
|
|
class TestIsNewer:
|
|
def test_latest_strictly_greater_returns_true(self):
|
|
assert _is_newer("0.8.0", "0.7.4") is True
|
|
|
|
def test_equal_versions_returns_false(self):
|
|
assert _is_newer("0.7.4", "0.7.4") is False
|
|
|
|
def test_current_greater_than_latest_returns_false(self):
|
|
assert _is_newer("0.7.0", "0.7.4") is False
|
|
|
|
def test_dev_build_ahead_of_release_returns_false(self):
|
|
assert _is_newer("0.7.4", "0.7.5.dev0") is False
|
|
|
|
def test_invalid_version_returns_false(self):
|
|
assert _is_newer("not-a-version", "0.7.4") is False
|
|
|
|
def test_local_version_containing_unknown_is_not_treated_as_sentinel(self):
|
|
assert _is_newer("1.2.4", "1.2.3+unknown") is True
|
|
|
|
|
|
class TestInstalledVersion:
|
|
def test_invalid_metadata_error_returns_unknown(self):
|
|
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
|
if invalid_metadata_error is None:
|
|
# Python versions without InvalidMetadataError: simulate with a
|
|
# custom exception to verify the guarded except path works.
|
|
class _FakeInvalidMetadataError(Exception):
|
|
pass
|
|
invalid_metadata_error = _FakeInvalidMetadataError
|
|
# Patch the attribute onto importlib.metadata so the production
|
|
# getattr() finds it during this test.
|
|
with patch.object(importlib.metadata, "InvalidMetadataError", invalid_metadata_error, create=True):
|
|
with patch(
|
|
"importlib.metadata.version",
|
|
side_effect=invalid_metadata_error("bad metadata"),
|
|
):
|
|
assert _get_installed_version() == "unknown"
|
|
else:
|
|
with patch(
|
|
"importlib.metadata.version",
|
|
side_effect=invalid_metadata_error("bad metadata"),
|
|
):
|
|
assert _get_installed_version() == "unknown"
|
|
|
|
|
|
class TestNormalizeTag:
|
|
def test_strips_single_leading_v(self):
|
|
assert _normalize_tag("v0.7.4") == "0.7.4"
|
|
|
|
def test_idempotent_when_no_leading_v(self):
|
|
assert _normalize_tag("0.7.4") == "0.7.4"
|
|
|
|
def test_strips_exactly_one_v(self):
|
|
assert _normalize_tag("vv0.7.4") == "v0.7.4"
|
|
|
|
def test_empty_string_passthrough(self):
|
|
assert _normalize_tag("") == ""
|
|
|
|
|
|
class TestUserStory1:
|
|
def test_newer_available_prints_update_and_install_command(self):
|
|
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
return_value=mock_urlopen_response({"tag_name": "v0.9.0"}),
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
output = strip_ansi(result.output)
|
|
assert result.exit_code == 0
|
|
assert "Update available" in output
|
|
assert "0.7.4" in output
|
|
assert "0.9.0" in output
|
|
assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output
|
|
|
|
def test_up_to_date_prints_current_only(self):
|
|
with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
return_value=mock_urlopen_response({"tag_name": "v0.9.0"}),
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
output = strip_ansi(result.output)
|
|
assert result.exit_code == 0
|
|
assert "Up to date: 0.9.0" in output
|
|
assert "Update available" not in output
|
|
assert "git+https://" not in output
|
|
|
|
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
|
with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
return_value=mock_urlopen_response({"tag_name": "v0.7.4"}),
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
output = strip_ansi(result.output)
|
|
assert result.exit_code == 0
|
|
assert "Update available" not in output
|
|
assert "Up to date" in output
|
|
|
|
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
|
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
return_value=mock_urlopen_response({"tag_name": "v0.7.4"}),
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
output = strip_ansi(result.output)
|
|
assert result.exit_code == 0
|
|
assert "Current version could not be determined" in output
|
|
assert "Latest release: v0.7.4" in output
|
|
assert "0.7.4" in output
|
|
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
|
assert "specify self upgrade" in output
|
|
assert "pipx install --force git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
|
|
|
def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self):
|
|
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
return_value=mock_urlopen_response({"tag_name": "v0.9.0;echo unsafe"}),
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
output = strip_ansi(result.output)
|
|
assert result.exit_code == 0
|
|
assert "Latest release: vX.Y.Z" in output
|
|
assert "Could not validate latest release tag from GitHub." in output
|
|
assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output
|
|
assert "v0.9.0;echo unsafe" not in output
|
|
|
|
def test_unparseable_tag_reports_validation_failure_without_raw_tag(self):
|
|
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
return_value=mock_urlopen_response({"tag_name": "not-a-version"}),
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
output = strip_ansi(result.output)
|
|
assert result.exit_code == 0
|
|
assert "Update available" not in output
|
|
assert "Up to date" not in output
|
|
assert "Could not validate latest release tag from GitHub." in output
|
|
assert "Latest release: vX.Y.Z" in output
|
|
assert "0.7.4" in output
|
|
assert "not-a-version" not in output
|
|
assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output
|
|
|
|
|
|
class TestFailureCategorization:
|
|
def test_urlerror_maps_to_offline(self):
|
|
with patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
side_effect=urllib.error.URLError("no route to host"),
|
|
):
|
|
tag, reason = _fetch_latest_release_tag()
|
|
assert tag is None
|
|
assert reason == "offline or timeout"
|
|
|
|
def test_timeout_maps_to_offline(self):
|
|
with patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
side_effect=TimeoutError(),
|
|
):
|
|
tag, reason = _fetch_latest_release_tag()
|
|
assert tag is None
|
|
assert reason == "offline or timeout"
|
|
|
|
def test_403_maps_to_rate_limited(self):
|
|
with patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
side_effect=_http_error(403, "rate limited"),
|
|
):
|
|
tag, reason = _fetch_latest_release_tag()
|
|
assert tag is None
|
|
assert reason == _RATE_LIMITED_REASON
|
|
|
|
@pytest.mark.parametrize("code", [404, 500, 502])
|
|
def test_other_http_uses_code_string(self, code):
|
|
with patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
side_effect=_http_error(code, "oops"),
|
|
):
|
|
tag, reason = _fetch_latest_release_tag()
|
|
assert tag is None
|
|
assert reason == f"HTTP {code}"
|
|
|
|
def test_generic_exception_propagates(self):
|
|
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
|
|
with patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
side_effect=RuntimeError("boom"),
|
|
):
|
|
with pytest.raises(RuntimeError):
|
|
_fetch_latest_release_tag()
|
|
|
|
|
|
_FAILURE_CASES = [
|
|
("offline or timeout", urllib.error.URLError("down")),
|
|
(_RATE_LIMITED_REASON, _http_error(403)),
|
|
("HTTP 500", _http_error(500)),
|
|
]
|
|
|
|
|
|
class TestUserStory2:
|
|
@pytest.mark.parametrize("expected_reason, side_effect", _FAILURE_CASES)
|
|
def test_failure_prints_installed_plus_one_line_reason(
|
|
self, expected_reason, side_effect
|
|
):
|
|
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
output = strip_ansi(result.output)
|
|
assert "Installed: 0.7.4" in output
|
|
if expected_reason == _RATE_LIMITED_REASON:
|
|
assert "Could not check latest release: rate limited" in output
|
|
assert "~/.specify/auth.json" in output
|
|
else:
|
|
assert f"Could not check latest release: {expected_reason}" in output
|
|
|
|
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
|
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
|
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
assert result.exit_code == 0
|
|
|
|
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
|
def test_failure_output_contains_no_traceback_no_url(
|
|
self, _expected_reason, side_effect
|
|
):
|
|
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
combined = (result.output or "") + (result.stderr or "")
|
|
combined = strip_ansi(combined)
|
|
assert "Traceback" not in combined
|
|
assert "https://api.github.com" not in combined
|
|
|
|
|
|
def _capture_request_via_urlopen():
|
|
captured = {}
|
|
|
|
def _side_effect(req, *args, **kwargs):
|
|
captured["request"] = req
|
|
return mock_urlopen_response({"tag_name": "v0.7.4"})
|
|
|
|
return captured, _side_effect
|
|
|
|
|
|
def _capture_request_via_auth_opener():
|
|
captured = {}
|
|
|
|
def _side_effect(req, *args, **kwargs):
|
|
captured["request"] = req
|
|
return mock_urlopen_response({"tag_name": "v0.7.4"})
|
|
|
|
opener = MagicMock()
|
|
opener.open.side_effect = _side_effect
|
|
return captured, opener
|
|
|
|
|
|
def _inject_github_config(monkeypatch, token_env="GH_TOKEN"):
|
|
from tests.auth_helpers import inject_github_config
|
|
inject_github_config(monkeypatch, token_env)
|
|
|
|
|
|
class TestUserStory3:
|
|
def test_gh_token_attached_as_bearer_header(self, monkeypatch):
|
|
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
captured, opener = _capture_request_via_auth_opener()
|
|
with patch(
|
|
"specify_cli.authentication.http.urllib.request.build_opener",
|
|
return_value=opener,
|
|
):
|
|
_fetch_latest_release_tag()
|
|
req = captured["request"]
|
|
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
|
|
|
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
|
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
captured, opener = _capture_request_via_auth_opener()
|
|
with patch(
|
|
"specify_cli.authentication.http.urllib.request.build_opener",
|
|
return_value=opener,
|
|
):
|
|
_fetch_latest_release_tag()
|
|
req = captured["request"]
|
|
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
|
|
|
def test_no_authorization_header_when_both_unset(self, monkeypatch):
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
captured, side_effect = _capture_request_via_urlopen()
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
|
_fetch_latest_release_tag()
|
|
req = captured["request"]
|
|
assert req.get_header("Authorization") is None
|
|
|
|
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
|
|
monkeypatch.setenv("GH_TOKEN", "")
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
captured, side_effect = _capture_request_via_urlopen()
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
|
_fetch_latest_release_tag()
|
|
req = captured["request"]
|
|
assert req.get_header("Authorization") is None
|
|
|
|
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
|
|
monkeypatch.setenv("GH_TOKEN", " ")
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
captured, side_effect = _capture_request_via_urlopen()
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
|
_fetch_latest_release_tag()
|
|
req = captured["request"]
|
|
assert req.get_header("Authorization") is None
|
|
|
|
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
|
|
monkeypatch.setenv("GH_TOKEN", " ")
|
|
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
|
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
captured, opener = _capture_request_via_auth_opener()
|
|
with patch(
|
|
"specify_cli.authentication.http.urllib.request.build_opener",
|
|
return_value=opener,
|
|
):
|
|
_fetch_latest_release_tag()
|
|
req = captured["request"]
|
|
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
|
|
|
@pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES)
|
|
def test_gh_token_never_appears_in_failure_output(
|
|
self, _reason, side_effect, monkeypatch
|
|
):
|
|
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
|
assert SENTINEL_GH_TOKEN not in combined
|
|
|
|
@pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES)
|
|
def test_github_token_never_appears_in_failure_output(
|
|
self, _reason, side_effect, monkeypatch
|
|
):
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
|
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
|
):
|
|
result = runner.invoke(app, ["self", "check"])
|
|
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
|
assert SENTINEL_GITHUB_TOKEN not in combined
|