Preflight and updater tests no longer probe 8081/8082 or GitHub. Hermetic runs fail-fast on non-loopback sockets. GUI helpers are importable without customtkinter. CI installs the web extra, and pytest-timeout plus a wall-clock wrapper bound the suite.
314 lines
13 KiB
Python
314 lines
13 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
|
||
|
||
# check_updates remains synchronous even when async_runner is provided.
|
||
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" not in dispatched, "проверка обновлений не должна уходить в фон без данных"
|
||
assert res_async.get("data"), "ответ без данных: интерфейс не узнает о наличии обновления"
|
||
mock_chk.assert_called()
|
||
|
||
# 3. apply_update async
|
||
dispatched = []
|
||
|
||
def mock_runner(fn, name):
|
||
dispatched.append(name)
|
||
|
||
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
|