306 lines
12 KiB
Python
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
|