mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-06 20:46:48 -07:00
071f0cf868
Symptom: user on the `dev` branch upgrading from v2.10.0 to v2.10.2 saw
the "Upgrade now? [y/N]" prompt fire on every restart. Accepting it ran
the install pipeline successfully but the next launch re-prompted the
same way.
Root cause: release tags in this repo live on main-side merge commits
(e.g. v2.10.2 is on the `Merge branch 'dev' into main` commit). That
commit is *downstream* of `dev`, so walking back from dev's HEAD,
setuptools-scm finds only v2.10.0 and reports e.g. `2.10.0.post1.dev9`.
After hate_crack/__init__.py strips the post/dev suffix, `__version__`
is `"2.10.0"` again. check_for_updates() sees `2.10.2 > 2.10.0` and
re-prompts forever. `git pull` on dev is a no-op because dev is fully
synced — the tag just isn't on the branch.
Fix: _run_upgrade() now detects the current branch via `git symbolic-ref
--short HEAD`. If it's not `main`, refuse to clobber uncommitted work
(git status --porcelain), then `git checkout main` before running the
existing pull/install pipeline. The release-tagged commit is now on
HEAD, setuptools-scm regenerates a clean version, and the loop ends.
Safety guards:
- Uncommitted changes: bail with manual-steps message (exit 1) — no
git checkout attempted.
- main checked out in another worktree: surface git's stderr and bail
with manual instructions.
- Detached HEAD (symbolic-ref returncode != 0): skip the switch and
let the existing flow run — no regression for users on a tag.
- Already on main: skip the switch block; behavior unchanged.
Tests (tests/test_version_check.py):
- test_run_upgrade_switches_from_dev_to_main_then_upgrades
- test_run_upgrade_bails_when_non_main_branch_is_dirty
- test_run_upgrade_bails_when_checkout_main_fails
- test_run_upgrade_skips_switch_when_already_on_main
- test_run_upgrade_skips_switch_on_detached_head
Four pre-existing tests updated to thread an extra branch_proc mock
through the new symbolic-ref subprocess call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
422 lines
16 KiB
Python
422 lines
16 KiB
Python
"""Tests for the startup version check feature."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def hc_module():
|
|
"""Load hate_crack.main with SKIP_INIT enabled."""
|
|
import os
|
|
import importlib
|
|
|
|
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
|
|
mod = importlib.import_module("hate_crack.main")
|
|
return mod
|
|
|
|
|
|
class TestCheckForUpdates:
|
|
"""Tests for check_for_updates()."""
|
|
|
|
def test_newer_version_prints_update_notice(self, hc_module, capsys):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"tag_name": "v99.0.0"}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", True
|
|
), patch("builtins.input", return_value="n"):
|
|
mock_requests.get.return_value = mock_resp
|
|
hc_module.check_for_updates()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Update available: 99.0.0" in output
|
|
assert "github.com/trustedsec/hate_crack/releases" in output
|
|
|
|
def test_same_version_prints_nothing(self, hc_module, capsys):
|
|
from hate_crack import __version__
|
|
|
|
local_base = __version__.split("+")[0]
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"tag_name": f"v{local_base}"}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", True
|
|
):
|
|
mock_requests.get.return_value = mock_resp
|
|
hc_module.check_for_updates()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Update available" not in output
|
|
|
|
def test_older_version_prints_nothing(self, hc_module, capsys):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"tag_name": "v0.0.1"}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
with (
|
|
patch.object(hc_module, "requests") as mock_requests,
|
|
patch.object(hc_module, "REQUESTS_AVAILABLE", True),
|
|
patch("hate_crack.__version__", "2.0"),
|
|
):
|
|
mock_requests.get.return_value = mock_resp
|
|
hc_module.check_for_updates()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Update available" not in output
|
|
|
|
def test_network_error_silently_handled(self, hc_module, capsys):
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", True
|
|
):
|
|
mock_requests.get.side_effect = ConnectionError("no network")
|
|
hc_module.check_for_updates()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Update available" not in output
|
|
assert "Error" not in output
|
|
|
|
def test_requests_unavailable_skips_check(self, hc_module, capsys):
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", False
|
|
):
|
|
hc_module.check_for_updates()
|
|
mock_requests.get.assert_not_called()
|
|
|
|
def test_config_disabled_skips_check(self, hc_module):
|
|
"""Verify that check_for_updates_enabled=False prevents the call in main()."""
|
|
# The config flag is checked in main() before calling check_for_updates().
|
|
# We verify the flag loads correctly from config.
|
|
assert hasattr(hc_module, "check_for_updates_enabled")
|
|
|
|
def test_tag_without_v_prefix(self, hc_module, capsys):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"tag_name": "99.0.0"}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", True
|
|
), patch("builtins.input", return_value="n"):
|
|
mock_requests.get.return_value = mock_resp
|
|
hc_module.check_for_updates()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Update available: 99.0.0" in output
|
|
|
|
def test_empty_tag_name_handled(self, hc_module, capsys):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"tag_name": ""}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", True
|
|
):
|
|
mock_requests.get.return_value = mock_resp
|
|
hc_module.check_for_updates()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Update available" not in output
|
|
|
|
def test_upgrade_declined_does_not_run_make(self, hc_module):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"tag_name": "v99.0.0"}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", True
|
|
), patch("builtins.input", return_value="n"), patch(
|
|
"subprocess.run"
|
|
) as mock_run:
|
|
mock_requests.get.return_value = mock_resp
|
|
hc_module.check_for_updates()
|
|
|
|
mock_run.assert_not_called()
|
|
|
|
def test_upgrade_accepted_runs_make_and_exits(self, hc_module, capsys):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"tag_name": "v99.0.0"}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
git_root_proc = MagicMock()
|
|
git_root_proc.returncode = 0
|
|
git_root_proc.stdout = "/fake/repo\n"
|
|
|
|
branch_proc = MagicMock()
|
|
branch_proc.returncode = 0
|
|
branch_proc.stdout = "main\n"
|
|
|
|
make_proc = MagicMock()
|
|
make_proc.returncode = 0
|
|
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", True
|
|
), patch("builtins.input", return_value="y"), patch(
|
|
"subprocess.run", side_effect=[git_root_proc, branch_proc, make_proc]
|
|
) as mock_run, pytest.raises(SystemExit):
|
|
mock_requests.get.return_value = mock_resp
|
|
hc_module.check_for_updates()
|
|
|
|
assert mock_run.call_count == 3
|
|
make_cmd = mock_run.call_args_list[2][0][0]
|
|
assert "git pull" in make_cmd
|
|
assert "make install" in make_cmd
|
|
assert mock_run.call_args_list[2][1]["cwd"] == "/fake/repo"
|
|
output = capsys.readouterr().out
|
|
assert "Upgrade complete" in output
|
|
|
|
def test_upgrade_failure_prints_error(self, hc_module, capsys):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"tag_name": "v99.0.0"}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
git_root_proc = MagicMock()
|
|
git_root_proc.returncode = 0
|
|
git_root_proc.stdout = "/fake/repo\n"
|
|
|
|
branch_proc = MagicMock()
|
|
branch_proc.returncode = 0
|
|
branch_proc.stdout = "main\n"
|
|
|
|
make_proc = MagicMock()
|
|
make_proc.returncode = 1
|
|
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", True
|
|
), patch("builtins.input", return_value="y"), patch(
|
|
"subprocess.run", side_effect=[git_root_proc, branch_proc, make_proc]
|
|
), patch("shutil.which", return_value="/usr/local/bin/uv"), patch(
|
|
"os.path.isfile", return_value=True
|
|
), pytest.raises(
|
|
SystemExit
|
|
):
|
|
mock_requests.get.return_value = mock_resp
|
|
hc_module.check_for_updates()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Upgrade failed" in output
|
|
|
|
def test_upgrade_no_git_repo_prints_manual_instructions(self, hc_module, capsys):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"tag_name": "v99.0.0"}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
git_root_proc = MagicMock()
|
|
git_root_proc.returncode = 128
|
|
git_root_proc.stdout = ""
|
|
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", True
|
|
), patch("builtins.input", return_value="y"), patch(
|
|
"subprocess.run", return_value=git_root_proc
|
|
), pytest.raises(
|
|
SystemExit
|
|
):
|
|
mock_requests.get.return_value = mock_resp
|
|
hc_module.check_for_updates()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Run manually" in output
|
|
|
|
|
|
class TestRunUpgrade:
|
|
"""Tests for _run_upgrade() called directly via --update flag."""
|
|
|
|
def test_run_upgrade_success(self, hc_module, capsys):
|
|
git_root_proc = MagicMock()
|
|
git_root_proc.returncode = 0
|
|
git_root_proc.stdout = "/fake/repo\n"
|
|
|
|
branch_proc = MagicMock()
|
|
branch_proc.returncode = 0
|
|
branch_proc.stdout = "main\n"
|
|
|
|
make_proc = MagicMock()
|
|
make_proc.returncode = 0
|
|
|
|
with patch(
|
|
"subprocess.run", side_effect=[git_root_proc, branch_proc, make_proc]
|
|
) as mock_run, pytest.raises(SystemExit) as exc:
|
|
hc_module._run_upgrade()
|
|
|
|
assert exc.value.code == 0
|
|
assert mock_run.call_count == 3
|
|
make_cmd = mock_run.call_args_list[2][0][0]
|
|
assert "git pull" in make_cmd
|
|
assert "make install" in make_cmd
|
|
assert mock_run.call_args_list[2][1]["cwd"] == "/fake/repo"
|
|
output = capsys.readouterr().out
|
|
assert "Upgrade complete" in output
|
|
|
|
def test_run_upgrade_make_failure(self, hc_module, capsys):
|
|
git_root_proc = MagicMock()
|
|
git_root_proc.returncode = 0
|
|
git_root_proc.stdout = "/fake/repo\n"
|
|
|
|
branch_proc = MagicMock()
|
|
branch_proc.returncode = 0
|
|
branch_proc.stdout = "main\n"
|
|
|
|
make_proc = MagicMock()
|
|
make_proc.returncode = 1
|
|
|
|
with patch(
|
|
"subprocess.run", side_effect=[git_root_proc, branch_proc, make_proc]
|
|
), pytest.raises(SystemExit) as exc:
|
|
hc_module._run_upgrade()
|
|
|
|
assert exc.value.code == 1
|
|
output = capsys.readouterr().out
|
|
assert "Upgrade failed" in output
|
|
|
|
def test_run_upgrade_no_git_repo(self, hc_module, capsys):
|
|
git_root_proc = MagicMock()
|
|
git_root_proc.returncode = 128
|
|
git_root_proc.stdout = ""
|
|
|
|
with patch("subprocess.run", return_value=git_root_proc), pytest.raises(SystemExit) as exc:
|
|
hc_module._run_upgrade()
|
|
|
|
assert exc.value.code == 1
|
|
output = capsys.readouterr().out
|
|
assert "Run manually" in output
|
|
|
|
def test_upgrade_prompt_ctrl_c_continues(self, hc_module, capsys):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {"tag_name": "v99.0.0"}
|
|
mock_resp.raise_for_status = MagicMock()
|
|
|
|
with patch.object(hc_module, "requests") as mock_requests, patch.object(
|
|
hc_module, "REQUESTS_AVAILABLE", True
|
|
), patch("builtins.input", side_effect=KeyboardInterrupt), patch(
|
|
"subprocess.run"
|
|
) as mock_run:
|
|
mock_requests.get.return_value = mock_resp
|
|
hc_module.check_for_updates()
|
|
|
|
mock_run.assert_not_called()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Branch-switch behavior: when the user runs the upgrade from a
|
|
# non-main checkout, release tags aren't reachable from HEAD and
|
|
# the upgrade no-ops (loops). _run_upgrade() must switch to main
|
|
# first, with safety guards.
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_run_upgrade_switches_from_dev_to_main_then_upgrades(
|
|
self, hc_module, capsys
|
|
):
|
|
git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n")
|
|
branch_proc = MagicMock(returncode=0, stdout="dev\n")
|
|
status_proc = MagicMock(returncode=0, stdout="")
|
|
checkout_proc = MagicMock(returncode=0, stdout="", stderr="")
|
|
make_proc = MagicMock(returncode=0)
|
|
|
|
with patch(
|
|
"subprocess.run",
|
|
side_effect=[
|
|
git_root_proc,
|
|
branch_proc,
|
|
status_proc,
|
|
checkout_proc,
|
|
make_proc,
|
|
],
|
|
) as mock_run, pytest.raises(SystemExit) as exc:
|
|
hc_module._run_upgrade()
|
|
|
|
assert exc.value.code == 0
|
|
assert mock_run.call_count == 5
|
|
# Third call is the checkout to main.
|
|
checkout_call = mock_run.call_args_list[3]
|
|
assert checkout_call[0][0] == ["git", "checkout", "main"]
|
|
# Final call is the shell upgrade.
|
|
upgrade_cmd = mock_run.call_args_list[4][0][0]
|
|
assert "git pull" in upgrade_cmd
|
|
assert "git fetch --tags" in upgrade_cmd
|
|
output = capsys.readouterr().out
|
|
assert "Switching from 'dev' to 'main'" in output
|
|
|
|
def test_run_upgrade_bails_when_non_main_branch_is_dirty(
|
|
self, hc_module, capsys
|
|
):
|
|
git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n")
|
|
branch_proc = MagicMock(returncode=0, stdout="feat/x\n")
|
|
status_proc = MagicMock(returncode=0, stdout=" M hate_crack/main.py\n")
|
|
|
|
with patch(
|
|
"subprocess.run",
|
|
side_effect=[git_root_proc, branch_proc, status_proc],
|
|
) as mock_run, pytest.raises(SystemExit) as exc:
|
|
hc_module._run_upgrade()
|
|
|
|
assert exc.value.code == 1
|
|
# No checkout, no upgrade command should fire after the bail.
|
|
assert mock_run.call_count == 3
|
|
all_call_args = [c[0][0] for c in mock_run.call_args_list]
|
|
assert ["git", "checkout", "main"] not in all_call_args
|
|
output = capsys.readouterr().out
|
|
assert "uncommitted changes" in output
|
|
assert "feat/x" in output
|
|
|
|
def test_run_upgrade_bails_when_checkout_main_fails(self, hc_module, capsys):
|
|
git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n")
|
|
branch_proc = MagicMock(returncode=0, stdout="dev\n")
|
|
status_proc = MagicMock(returncode=0, stdout="")
|
|
checkout_proc = MagicMock(
|
|
returncode=1,
|
|
stdout="",
|
|
stderr="error: 'main' is already checked out at '/other/wt'\n",
|
|
)
|
|
|
|
with patch(
|
|
"subprocess.run",
|
|
side_effect=[git_root_proc, branch_proc, status_proc, checkout_proc],
|
|
), pytest.raises(SystemExit) as exc:
|
|
hc_module._run_upgrade()
|
|
|
|
assert exc.value.code == 1
|
|
output = capsys.readouterr().out
|
|
assert "Failed to switch to main" in output
|
|
assert "already checked out" in output
|
|
|
|
def test_run_upgrade_skips_switch_when_already_on_main(
|
|
self, hc_module, capsys
|
|
):
|
|
git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n")
|
|
branch_proc = MagicMock(returncode=0, stdout="main\n")
|
|
make_proc = MagicMock(returncode=0)
|
|
|
|
with patch(
|
|
"subprocess.run",
|
|
side_effect=[git_root_proc, branch_proc, make_proc],
|
|
) as mock_run, pytest.raises(SystemExit) as exc:
|
|
hc_module._run_upgrade()
|
|
|
|
assert exc.value.code == 0
|
|
# Only rev-parse + symbolic-ref + upgrade shell. No checkout.
|
|
assert mock_run.call_count == 3
|
|
all_call_args = [c[0][0] for c in mock_run.call_args_list]
|
|
assert ["git", "checkout", "main"] not in all_call_args
|
|
output = capsys.readouterr().out
|
|
assert "Switching from" not in output
|
|
|
|
def test_run_upgrade_skips_switch_on_detached_head(self, hc_module, capsys):
|
|
"""Detached HEAD: symbolic-ref returns non-zero. We should not
|
|
attempt a branch switch (there's nothing to switch from) — let
|
|
the existing upgrade flow proceed."""
|
|
git_root_proc = MagicMock(returncode=0, stdout="/fake/repo\n")
|
|
branch_proc = MagicMock(returncode=1, stdout="", stderr="fatal: ref HEAD is not a symbolic ref\n")
|
|
make_proc = MagicMock(returncode=0)
|
|
|
|
with patch(
|
|
"subprocess.run",
|
|
side_effect=[git_root_proc, branch_proc, make_proc],
|
|
) as mock_run, pytest.raises(SystemExit) as exc:
|
|
hc_module._run_upgrade()
|
|
|
|
assert exc.value.code == 0
|
|
assert mock_run.call_count == 3
|
|
all_call_args = [c[0][0] for c in mock_run.call_args_list]
|
|
assert ["git", "checkout", "main"] not in all_call_args
|