hermes-hub/tests/test_a55_account_connection.py
Hermes Team 9641957d5b fix(antigravity): вход завершался успешно, а agy требовал войти снова
agy 2.0 читает учётные данные не из .gemini/oauth_creds.json — это формат
Gemini CLI. Свой токен он берёт из .gemini/antigravity-cli/antigravity-oauth-token
в виде {"auth_method": ..., "token": {...}}. Хаб этот файл никогда не создавал,
поэтому «Авторизация успешно завершена» соседствовала с ответом
«Please sign in to view available models».

Установлено сравнением рабочего профиля владельца с неработающим: оба имели
oauth_creds.json одинакового размера, различие было только в этом файле.
Значение auth_method («consumer») взято из рабочего профиля, а не выведено
из общих соображений.

Срок годности пишется в обоих видах: expiry_date в миллисекундах для формата
Node и expiry строкой RFC3339 для Go-шного oauth2.Token.

Заодно устранён рост номеров профилей: слот выбирался до входа, когда почта
ещё неизвестна, и повторный вход тем же аккаунтом занимал очередной свободный
слот — один аккаунт владельца расползся на ag-2, ag-3, ag-4. Теперь после
опознания почты учётные данные возвращаются в слот, который этот аккаунт уже
занимает. Функция проверки двойников в проекте была, но её никто не вызывал.

Версия поднята до 0.1.3, чтобы отличать сборки на глаз. Проверка версии в
тестах сверяется с исходником, а не с записанным числом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:37:27 +07:00

192 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for Task A55: Account Connection Defect Fixes (P0-1 through P0-5)."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.version import __version__, VERSION_INFO
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.adapters.ollama_adapter import OllamaAdapter, DEFAULT_OLLAMA_BASE_URL
from antigravity_provider.router.connection_preflight import validate_connection
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.web.server import app
# ── P0-1: Antigravity & Browser Auth Tests ──
def test_p0_1_add_account_unauthenticated_browser_auth_error_message():
"""add_account should return Russian browser auth message for antigravity and claude."""
with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": False}):
# Antigravity without token/completed auth
res_ag = ActionExecutor.execute("add_account", {
"provider": "antigravity",
"profile_id": "ag-1",
"token": "",
})
assert not res_ag["ok"]
assert "Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата." in res_ag["message"]
# Claude without token/completed auth
res_cl = ActionExecutor.execute("add_account", {
"provider": "claude",
"profile_id": "claude-1",
"token": "",
})
assert not res_cl["ok"]
assert "Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата." in res_cl["message"]
def test_p0_1_profile_auth_manager_get_profile_status_antigravity():
"""get_profile_status should return authenticated: False when auth_data is empty or invalid."""
# Unauthenticated/empty auth_data
with patch.object(ProfileAuthManager, "load_profile_auth", return_value={}):
st = ProfileAuthManager.get_profile_status("antigravity", "ag-1")
assert st["authenticated"] is False
assert st["status"] == "NOT_CONFIGURED"
# Authenticated with access_token
with patch.object(ProfileAuthManager, "load_profile_auth", return_value={"token": {"access_token": "ya29.valid-token"}}):
st = ProfileAuthManager.get_profile_status("antigravity", "ag-1")
assert st["authenticated"] is True
assert st["status"] == "AUTHENTICATED"
def test_p0_1_load_profile_auth_gemini_oauth_creds_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""load_profile_auth should check .gemini/oauth_creds.json if auth.json is missing."""
monkeypatch.setattr("antigravity_provider.paths.get_hermes_home", lambda: tmp_path)
pdir = tmp_path / "agy_profiles" / "ag-5"
gemini_dir = pdir / ".gemini"
gemini_dir.mkdir(parents=True, exist_ok=True)
creds_file = gemini_dir / "oauth_creds.json"
creds_file.write_text(json.dumps({"access_token": "token-123", "refresh_token": "refresh-456"}), encoding="utf-8")
auth = ProfileAuthManager.load_profile_auth("antigravity", "ag-5")
assert auth is not None
assert auth.get("provider") == "antigravity"
assert auth.get("token", {}).get("access_token") == "token-123"
# ── P0-2: Ollama Base URL & Connection Error Tests ──
def test_p0_2_ollama_default_base_url():
"""DEFAULT_OLLAMA_BASE_URL must be http://127.0.0.1:11434 (without trailing /v1)."""
assert DEFAULT_OLLAMA_BASE_URL == "http://127.0.0.1:11434"
def test_p0_2_ollama_adapter_url_builders():
"""OllamaAdapter URL methods should handle base_urls with or without /v1 cleanly."""
adapter = OllamaAdapter()
# Standard base url without /v1
assert adapter._get_native_host("http://127.0.0.1:11434") == "http://127.0.0.1:11434"
assert adapter._get_chat_url("http://127.0.0.1:11434") == "http://127.0.0.1:11434/v1/chat/completions"
assert adapter._get_models_url("http://127.0.0.1:11434") == "http://127.0.0.1:11434/v1/models"
# Base url with /v1
assert adapter._get_native_host("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434"
assert adapter._get_chat_url("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434/v1/chat/completions"
assert adapter._get_models_url("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434/v1/models"
# Base url with trailing slash
assert adapter._get_native_host("http://192.168.1.81:11434/") == "http://192.168.1.81:11434"
assert adapter._get_chat_url("http://192.168.1.81:11434/") == "http://192.168.1.81:11434/v1/chat/completions"
def test_p0_2_ollama_connection_refused_helpful_message():
"""Ollama connection refused error should provide network IP guidance."""
# Test connection preflight when endpoint is down
res = validate_connection("ollama", token="", base_url="http://127.0.0.1:11434")
assert not res["ok"]
assert "Не удалось подключиться к http://127.0.0.1:11434 (Connection refused)" in res["message"]
assert "http://192.168.1.81:11434" in res["message"]
# ── P0-3: Antigravity Dynamic Model Discovery Tests ──
def test_p0_3_antigravity_dynamic_model_discovery():
"""ModelDiscoveryService probe should find and query any authenticated Antigravity profile."""
discovery = ModelDiscoveryService.get()
with patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status") as mock_st, \
patch("antigravity_provider.agy_subprocess.discover_models", return_value={"gemini-2.5-pro": "gemini-2.5-pro"}) as mock_disc:
# Simulate ag-3 being authenticated
def status_side_effect(prov, pid):
if pid == "ag-3":
return {"authenticated": True, "provider": prov, "profile_id": pid}
return {"authenticated": False, "provider": prov, "profile_id": pid}
mock_st.side_effect = status_side_effect
models, err = discovery._probe_provider("antigravity")
assert err is None
assert models == ["gemini-2.5-pro"]
mock_disc.assert_called_with(profile_id="ag-3")
# ── P0-4: Version Info & API Propagation Tests ──
def test_p0_4_version_single_source_of_truth():
"""Номер версии и VERSION_INFO обязаны совпадать между собой.
Сверять с записанным в тесте числом бессмысленно: при каждой сборке его
пришлось бы править, и тест превращался бы в напоминание, а не в проверку.
Смысл требования — единый источник, его и проверяем.
"""
assert VERSION_INFO == tuple(int(part) for part in __version__.split("."))
def test_p0_4_version_in_api_endpoints():
"""Все точки API обязаны отдавать ту же версию, что и пакет."""
client = TestClient(app)
# /api/health
res_health = client.get("/api/health")
assert res_health.status_code == 200
assert res_health.json().get("version") == __version__
# /api/settings
res_settings = client.get("/api/settings")
assert res_settings.status_code == 200
assert res_settings.json().get("version") == __version__
# /api/snapshot
res_snap = client.get("/api/snapshot")
assert res_snap.status_code == 200
snap = res_snap.json()
assert snap.get("version") == __version__
assert (snap.get("metrics") or {}).get("version") == __version__
# ── P0-5: Settings View & System Paths Tests ──
def test_p0_5_settings_api_returns_system_paths_and_hub_settings():
"""GET /api/settings must return system_paths, server host/port, token status, and hub settings."""
client = TestClient(app)
res = client.get("/api/settings")
assert res.status_code == 200
data = res.json()
# System paths dict
assert "system_paths" in data
sys_paths = data["system_paths"]
assert "hermes_home" in sys_paths
assert "config_dir" in sys_paths
assert "log_file" in sys_paths
# Server settings & tokens
assert "web_api_host" in data
assert "web_api_port" in data
assert "web_api_token_configured" in data
# Snapshot includes system_paths
res_snap = client.get("/api/snapshot")
assert res_snap.status_code == 200
snap = res_snap.json()
assert "system_paths" in snap
assert snap["system_paths"]["hermes_home"] == sys_paths["hermes_home"]