hermes-hub/tests/test_in_app_updates_a27.py

306 lines
12 KiB
Python

"""Hermes Hub — Task A27 In-App Updates Test Suite.
Verifies:
1. check_for_updates when commits match -> update_available = False, message = 'Установлена последняя сборка'.
2. check_for_updates when newer release commit exists -> update_available = True with full metadata.
3. Network error / 403 Rate Limit returns explicit error, never masked as 'no updates'.
4. SHA-256 validation against checksums.txt and rollback/rejection on hash mismatch.
5. Actions 'check_updates', 'apply_update', 'get_update_status' via ActionExecutor.
6. get_installed_commit extraction from manifest, git, and environment variables.
"""
from __future__ import annotations
import io
import json
import urllib.error
import zipfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.web.server import app
from antigravity_provider.updater.update_manager import (
UpdateManager,
UpdateManifest,
UpdateCheckResult,
compute_sha256,
extract_release_commit,
get_installed_commit,
is_allowed_update_host,
)
from antigravity_provider.version import __version__
@pytest.fixture
def client():
return TestClient(app)
# ── TEST 1: Commits Match -> No Update Available ──
@pytest.mark.unit
def test_check_for_updates_commits_match(monkeypatch, tmp_path):
"""When installed commit matches latest release commit, update_available must be False."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_GIT_COMMIT", "a1e1db74a3f123456789abcdef0123456789abcd")
mgr = UpdateManager()
mock_release = {
"tag_name": "build-2026.08.25",
"target_commitish": "a1e1db74a3f123456789abcdef0123456789abcd",
"name": "Hermes Hub Build 2026.08.25",
"body": "Release commit: a1e1db74a3f123456789abcdef0123456789abcd\nFixes and improvements.",
"published_at": "2026-08-25T10:00:00Z",
"assets": [
{
"name": "HermesHubSetup.exe",
"browser_download_url": "https://github.com/ochenstarik-ui/hermes-hub/releases/download/build-2026.08.25/HermesHubSetup.exe",
},
{
"name": "checksums.txt",
"browser_download_url": "https://github.com/ochenstarik-ui/hermes-hub/releases/download/build-2026.08.25/checksums.txt",
},
],
}
res = mgr.check_for_updates(release_dict=mock_release)
assert res.update_available is False
assert res.error is None
assert "последняя" in (res.message or "").lower()
assert res.installed_commit.startswith("a1e1db7")
assert res.latest_commit.startswith("a1e1db7")
# ── TEST 2: Newer Commit -> Update Available with Metadata ──
@pytest.mark.unit
def test_check_for_updates_newer_commit_available(monkeypatch, tmp_path):
"""When release commit differs from installed commit, update_available must be True."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_GIT_COMMIT", "1111111111111111111111111111111111111111")
mgr = UpdateManager()
mock_release = {
"tag_name": "build-2026.08.25",
"target_commitish": "9999999999999999999999999999999999999999",
"name": "Hermes Hub New Release",
"body": "Major speed improvements and new features.\nRelease commit: 9999999",
"published_at": "2026-08-25T12:00:00Z",
"assets": [
{
"name": "HermesHubSetup.exe",
"browser_download_url": "https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.2/HermesHubSetup.exe",
},
{
"name": "checksums.txt",
"browser_download_url": "https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.2/checksums.txt",
},
],
}
res = mgr.check_for_updates(release_dict=mock_release)
assert res.update_available is True
assert res.error is None
assert res.latest_commit.startswith("9999999")
assert res.installed_commit.startswith("1111111")
assert res.release_tag == "build-2026.08.25"
assert res.published_at == "2026-08-25T12:00:00Z"
assert "Major speed improvements" in (res.changelog or "")
assert "HermesHubSetup.exe" in res.assets
assert "checksums.txt" in res.assets
# ── TEST 3: Network Errors & 403 Rate Limit Are Not Masked ──
@pytest.mark.unit
def test_network_errors_and_rate_limit_handled_honestly(monkeypatch, tmp_path):
"""Network errors and HTTP 403 Rate Limit must return error message and NOT be masked as 'no updates'."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
mgr = UpdateManager(manifest_url="https://api.github.com/repos/ochenstarik-ui/hermes-hub/releases/latest")
# 1. HTTP 403 Rate Limit
mock_403 = urllib.error.HTTPError(
url=mgr.manifest_url,
code=403,
msg="rate limit exceeded",
hdrs={},
fp=io.BytesIO(b'{"message":"API rate limit exceeded"}'),
)
with patch("urllib.request.urlopen", side_effect=mock_403):
res = mgr.check_for_updates()
assert res.update_available is False
assert res.error is not None
assert "лимит" in res.error.lower() or "rate limit" in res.error.lower()
# 2. General URLError / Network offline
mock_network_err = urllib.error.URLError(reason="Name resolution failure / Offline")
with patch("urllib.request.urlopen", side_effect=mock_network_err):
res = mgr.check_for_updates()
assert res.update_available is False
assert res.error is not None
assert "сетевая ошибка" in res.error.lower() or "offline" in res.error.lower()
# ── TEST 4: SHA-256 Checksums.txt Validation & Mismatch Rejection ──
@pytest.mark.unit
def test_checksums_txt_verification_and_mismatch_rejection(monkeypatch, tmp_path):
"""install_latest_update downloads checksums.txt, verifies hash, and rejects tampered files."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
# Create dummy package file and compute real sha
pkg_file = tmp_path / "pkg.zip"
with zipfile.ZipFile(pkg_file, "w") as zf:
zf.writestr("test.py", "print('hello')")
real_sha = compute_sha256(pkg_file)
# Create dummy checksums.txt with WRONG hash
chk_file = tmp_path / "checksums.txt"
chk_file.write_text(f"0000000000000000000000000000000000000000000000000000000000000000 pkg.zip\n", encoding="utf-8")
mgr = UpdateManager()
check_res = UpdateCheckResult(
update_available=True,
current_version="0.1.1",
latest_version="0.1.2",
installed_commit="1111111",
latest_commit="2222222",
assets={
"pkg.zip": f"file://{pkg_file}",
"checksums.txt": f"file://{chk_file}",
},
)
ok, msg = mgr.install_latest_update(check_result=check_res)
assert ok is False
assert "контрольная сумма" in msg.lower() or "mismatch" in msg.lower() or "sha-256" in msg.lower()
# Now fix checksums.txt with correct hash
chk_file.write_text(f"{real_sha} pkg.zip\n", encoding="utf-8")
app_dir = tmp_path / "app"
app_dir.mkdir(parents=True, exist_ok=True)
(app_dir / "src").mkdir(parents=True, exist_ok=True)
ok_valid, msg_valid = mgr.install_latest_update(check_result=check_res, target_dir=app_dir)
assert ok_valid is True
# ── TEST 5: ActionExecutor check_updates and apply_update ──
@pytest.mark.unit
def test_action_executor_update_actions(monkeypatch, tmp_path):
"""ActionExecutor handles check_updates, apply_update, and get_update_status."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_GIT_COMMIT", "feedbeef12345678901234567890123456789012")
# 1. check_updates synchronous
mock_release = {
"tag_name": "build-2026.08.25",
"target_commitish": "feedbeef12345678901234567890123456789012",
"name": "Hermes Hub Match",
"body": "No changes.",
}
with patch.object(UpdateManager, "check_for_updates") as mock_chk:
mock_chk.return_value = UpdateCheckResult(
update_available=False,
current_version=__version__,
latest_version=__version__,
installed_commit="feedbeef12345678901234567890123456789012",
latest_commit="feedbeef12345678901234567890123456789012",
message="Установлена последняя сборка",
)
res = ActionExecutor.execute("check_updates", {})
assert res["ok"] is True
assert "data" in res
assert res["data"]["update_available"] is False
# 2. check_updates async
dispatched = []
def mock_runner(fn, name):
dispatched.append(name)
res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner)
assert res_async["ok"] is True
assert "CheckUpdates" in dispatched
# 3. apply_update async
res_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner)
assert res_apply_async["ok"] is True
assert "ApplyUpdate" in dispatched
# 4. get_update_status
res_status = ActionExecutor.execute("get_update_status", {})
assert res_status["ok"] is True
assert "data" in res_status
assert "installed_commit" in res_status["data"]
# ── TEST 6: get_installed_commit Extraction Logic ──
@pytest.mark.unit
def test_get_installed_commit_extraction(monkeypatch, tmp_path):
"""get_installed_commit extracts commit from env, deployment_manifest.json, and git."""
# 1. From environment variable
monkeypatch.setenv("HERMES_HUB_GIT_COMMIT", "abc1234567")
assert get_installed_commit() == "abc1234567"
# 2. From deployment_manifest.json in hermes home plugins dir
monkeypatch.delenv("HERMES_HUB_GIT_COMMIT", raising=False)
hermes_home = tmp_path / "hermes"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
plugin_dir = hermes_home / "plugins" / "antigravity-provider"
plugin_dir.mkdir(parents=True, exist_ok=True)
manifest_file = plugin_dir / "deployment_manifest.json"
manifest_file.write_text(json.dumps({"git_commit": "manifest_commit_7890"}), encoding="utf-8")
assert get_installed_commit() == "manifest_commit_7890"
# 3. Fallback to unknown when nothing exists
manifest_file.unlink()
# Mock git failure
with patch("subprocess.run", side_effect=Exception("no git")):
with patch("shutil.which", return_value=None):
assert get_installed_commit() == "unknown"
# ── TEST 7: extract_release_commit from Various Release Payloads ──
@pytest.mark.unit
def test_extract_release_commit_formats():
"""extract_release_commit extracts hex commit from various GitHub release structures."""
# Direct field
assert extract_release_commit({"git_commit": "a1e1db7"}) == "a1e1db7"
assert extract_release_commit({"build_commit": "4c2594b"}) == "4c2594b"
# Target commitish
assert extract_release_commit({"target_commitish": "a1e1db74a3f123456789abcdef0123456789abcd"}) == "a1e1db74a3f123456789abcdef0123456789abcd"
# In body: "Release commit: 7b7b527..."
assert extract_release_commit({
"name": "Build 2026.08.25",
"tag_name": "build-2026.08.25",
"body": "Commit: 7b7b527\nChangelog details...",
}) == "7b7b527"
# In name: "Hermes Hub 0.1.1 (a1e1db7)"
assert extract_release_commit({
"name": "Hermes Hub 0.1.1 (a1e1db7)",
"tag_name": "build-2026.08.25",
}) == "a1e1db7"
# ── TEST 8: GET /api/settings Includes Commit and Update Info ──
@pytest.mark.unit
def test_settings_endpoint_includes_commit_and_update_status(client, monkeypatch, tmp_path):
"""GET /api/settings returns installed_commit and last_update_check."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_GIT_COMMIT", "test_commit_123")
res = client.get("/api/settings")
assert res.status_code == 200
data = res.json()
assert data.get("installed_commit") == "test_commit_123"
assert "version" in data