1. P0-1: Восстановлены все веб-обработчики в app.js (openAddAccountWizard, handleNodeAccountChange, handleNodeModelChange, handleRefreshProviderModels, checkUpdates), связаны с потоками startDeviceAuth и startRedirectAuth, выбор слота обязателен и понятен пользователю. 2. P0-2: Добавлены адаптеры OpenRouter и NVIDIA NIM с поддержкой динамического base_url, множественных аккаунтов без ограничений, GET /models и честным отображением квот/«Н/Д». 3. P0-3: В мастере подключения локального провайдера реализована кнопка автопоиска (discover_local_models), отображение серверов, ошибок портов и автозаполнение. 4. P0-4: Полностью удален устаревший десктопный интерфейс CustomTkinter (router/ui/** 20 файлов, hermes_hub_app.py), зависимости customtkinter и pillow убраны из pyproject.toml и инсталляторов, оставлен единый ярлык «Hermes Hub». 5. 418 passed, 1 skipped, 4 deselected, ruff чисто.
355 lines
15 KiB
Python
355 lines
15 KiB
Python
"""Comprehensive tests for OpenAI Codex OAuth, Multi-Account Isolation, OpenCode Go, and Wizard Clipboard UX.
|
|
|
|
TEST A: CodexOAuthSession standard device flow (user code -> poll -> token exchange -> save profile auth)
|
|
TEST B: CodexOAuthSession manual token / JSON credential fallback
|
|
TEST C: Multi-account isolation: multiple Codex profiles saved and loaded independently without overwriting
|
|
TEST D: CodexAdapter._resolve_token resolves tokens for individual profiles from ProfileAuthManager
|
|
TEST E: CodexAdapter multi-profile switching and failover isolation
|
|
TEST F: ProfileAuthManager.get_profile_status works for both Codex OAuth and API Key modes
|
|
TEST G: ProfileAuthManager.extract_jwt_identity extracts email from OpenAI/Google JWT payloads
|
|
TEST H: HubEntry and enable_clipboard_shortcuts (Ctrl+V, Ctrl+C, Ctrl+X, Ctrl+A, Shift+Insert, Cyrillic shortcuts)
|
|
TEST I: Add Account Wizard: OpenCode Go API key paste with whitespace / newline stripping
|
|
TEST J: Add Account Wizard: Password masking does not corrupt backing value or clipboard insertion
|
|
TEST K: Zero-secret logging across Codex OAuth and API key flows
|
|
TEST L: Single completion lock prevents double token exchange / double file writes in CodexOAuthSession
|
|
TEST M: Codex OAuth session cleanup on cancellation / modal destroy
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email
|
|
from antigravity_provider.router.router_config import RouterProfileConfig
|
|
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
|
|
from antigravity_provider.router.codex_oauth import (
|
|
CodexOAuthSession,
|
|
start_codex_oauth,
|
|
get_codex_oauth_session,
|
|
cancel_codex_oauth_session,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_hermes_env(tmp_path, monkeypatch):
|
|
"""Ensure all tests run in an isolated HERMES_HOME."""
|
|
hermes_dir = tmp_path / "hermes_test"
|
|
hermes_dir.mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_dir))
|
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "localappdata"))
|
|
yield hermes_dir
|
|
|
|
|
|
def _make_jwt(payload: dict) -> str:
|
|
"""Helper to generate an unsigned test JWT."""
|
|
header_b64 = base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').decode("utf-8").rstrip("=")
|
|
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8").rstrip("=")
|
|
return f"{header_b64}.{payload_b64}.fake_signature"
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST A: CodexOAuthSession standard device flow
|
|
# ==============================================================================
|
|
def test_a_codex_oauth_standard_device_flow(monkeypatch):
|
|
"""Test start -> poll -> token exchange -> profile saving."""
|
|
session = CodexOAuthSession("codex-worker-1")
|
|
|
|
# Mock usercode endpoint
|
|
monkeypatch.setattr(
|
|
"antigravity_provider.router.codex_oauth._post_json",
|
|
lambda url, payload, **kwargs: {
|
|
"user_code": "TEST-1234",
|
|
"device_auth_id": "dev-auth-xyz",
|
|
"interval": 1,
|
|
} if "usercode" in url else {
|
|
"authorization_code": "auth-code-999",
|
|
"code_verifier": "verifier-111",
|
|
},
|
|
)
|
|
|
|
test_id_token = _make_jwt({"email": "dev1@openai.com", "sub": "usr_dev1"})
|
|
monkeypatch.setattr(
|
|
"antigravity_provider.router.codex_oauth._post_form_json",
|
|
lambda url, data, **kwargs: {
|
|
"access_token": "oa-acc-token-123",
|
|
"refresh_token": "oa-ref-token-456",
|
|
"id_token": test_id_token,
|
|
},
|
|
)
|
|
|
|
url, code = session.start()
|
|
assert "auth.openai.com/codex/device" in url
|
|
assert code == "TEST-1234"
|
|
|
|
# Wait for poll thread to exchange tokens
|
|
deadline = time.time() + 2.5
|
|
while time.time() < deadline and session.status != "completed":
|
|
time.sleep(0.1)
|
|
|
|
assert session.status == "completed"
|
|
assert session.completed_profile_info is not None
|
|
assert session.completed_profile_info["email"] == "dev1@openai.com"
|
|
|
|
# Verify saved on disk
|
|
auth_data = ProfileAuthManager.load_profile_auth("openai-codex", "codex-worker-1")
|
|
assert auth_data is not None
|
|
assert auth_data["profile_id"] == "codex-worker-1"
|
|
assert auth_data["token"]["access_token"] == "oa-acc-token-123"
|
|
assert auth_data["email"] == "dev1@openai.com"
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST B: CodexOAuthSession manual token / JSON credential fallback
|
|
# ==============================================================================
|
|
def test_b_codex_oauth_manual_token_fallback():
|
|
"""Test manual JSON credential insertion into Codex OAuth session."""
|
|
session = CodexOAuthSession("codex-worker-2")
|
|
test_id_token = _make_jwt({"email": "worker2@openai.com", "sub": "usr_w2"})
|
|
|
|
raw_json = json.dumps({
|
|
"access_token": "manual-access-token-777",
|
|
"refresh_token": "manual-refresh-token-888",
|
|
"id_token": test_id_token,
|
|
})
|
|
|
|
ok, msg = session.handle_manual_input(raw_json)
|
|
assert ok is True
|
|
assert session.status == "completed"
|
|
|
|
auth_data = ProfileAuthManager.load_profile_auth("openai-codex", "codex-worker-2")
|
|
assert auth_data is not None
|
|
assert auth_data["profile_id"] == "codex-worker-2"
|
|
assert auth_data["token"]["access_token"] == "manual-access-token-777"
|
|
assert auth_data["email"] == "worker2@openai.com"
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST C: Multi-account isolation
|
|
# ==============================================================================
|
|
def test_c_multi_account_codex_isolation():
|
|
"""Verify multiple Codex accounts are saved and loaded independently."""
|
|
# Save account 1 (codex-orch)
|
|
orch_data = {
|
|
"provider": "openai-codex",
|
|
"profile_id": "codex-orch",
|
|
"auth_mode": "oauth",
|
|
"token": {"access_token": "orch-token-1"},
|
|
"email": "orch@company.com",
|
|
}
|
|
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", orch_data)
|
|
|
|
# Save account 2 (codex-worker-1)
|
|
w1_data = {
|
|
"provider": "openai-codex",
|
|
"profile_id": "codex-worker-1",
|
|
"auth_mode": "oauth",
|
|
"token": {"access_token": "worker1-token-2"},
|
|
"email": "coder@company.com",
|
|
}
|
|
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", w1_data)
|
|
|
|
# Save account 3 (codex-worker-2 as API key)
|
|
w2_data = {
|
|
"provider": "openai-codex",
|
|
"profile_id": "codex-worker-2",
|
|
"auth_mode": "api_key",
|
|
"api_key": "sk-proj-testkey333333333333",
|
|
}
|
|
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-2", w2_data)
|
|
|
|
# Verify each loads its own independent data
|
|
loaded_orch = ProfileAuthManager.load_profile_auth("openai-codex", "codex-orch")
|
|
loaded_w1 = ProfileAuthManager.load_profile_auth("openai-codex", "codex-worker-1")
|
|
loaded_w2 = ProfileAuthManager.load_profile_auth("openai-codex", "codex-worker-2")
|
|
|
|
assert loaded_orch["token"]["access_token"] == "orch-token-1"
|
|
assert loaded_orch["email"] == "orch@company.com"
|
|
|
|
assert loaded_w1["token"]["access_token"] == "worker1-token-2"
|
|
assert loaded_w1["email"] == "coder@company.com"
|
|
|
|
assert loaded_w2["api_key"] == "sk-proj-testkey333333333333"
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST D: CodexAdapter._resolve_token per-profile isolation
|
|
# ==============================================================================
|
|
def test_d_codex_adapter_resolve_token():
|
|
"""Verify CodexAdapter._resolve_token looks up the exact profile credentials from ProfileAuthManager."""
|
|
adapter = CodexAdapter()
|
|
|
|
# Create 2 separate profiles
|
|
p1 = RouterProfileConfig(profile_id="codex-orch", provider="openai-codex")
|
|
p2 = RouterProfileConfig(profile_id="codex-worker-1", provider="openai-codex")
|
|
|
|
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {
|
|
"provider": "openai-codex", "profile_id": "codex-orch", "token": {"access_token": "token-orch-99"}
|
|
})
|
|
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", {
|
|
"provider": "openai-codex", "profile_id": "codex-worker-1", "token": {"access_token": "token-worker-11"}
|
|
})
|
|
|
|
t1 = adapter._resolve_token(p1)
|
|
t2 = adapter._resolve_token(p2)
|
|
|
|
assert t1 == "token-orch-99"
|
|
assert t2 == "token-worker-11"
|
|
assert t1 != t2
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST E: CodexAdapter multi-profile switching and failover isolation
|
|
# ==============================================================================
|
|
def test_e_codex_adapter_switching_and_failover():
|
|
"""Test switching profiles in CodexAdapter during execution."""
|
|
adapter = CodexAdapter()
|
|
|
|
p_primary = RouterProfileConfig(profile_id="codex-orch", provider="openai-codex")
|
|
p_spare = RouterProfileConfig(profile_id="codex-spare-1", provider="openai-codex")
|
|
|
|
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {
|
|
"provider": "openai-codex", "profile_id": "codex-orch", "api_key": "sk-primary-orch-key111"
|
|
})
|
|
ProfileAuthManager.save_profile_auth("openai-codex", "codex-spare-1", {
|
|
"provider": "openai-codex", "profile_id": "codex-spare-1", "api_key": "sk-spare-orch-key222"
|
|
})
|
|
|
|
assert adapter._resolve_token(p_primary) == "sk-primary-orch-key111"
|
|
assert adapter._resolve_token(p_spare) == "sk-spare-orch-key222"
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST F: ProfileAuthManager.get_profile_status for OAuth and API Key
|
|
# ==============================================================================
|
|
def test_f_profile_auth_manager_status():
|
|
"""Verify get_profile_status properly reports OAuth vs API Key profiles."""
|
|
test_id_token = _make_jwt({"email": "testuser@openai.com", "sub": "usr_1"})
|
|
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {
|
|
"provider": "openai-codex",
|
|
"profile_id": "codex-orch",
|
|
"token": {"access_token": "test-access", "id_token": test_id_token},
|
|
})
|
|
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", {
|
|
"provider": "openai-codex",
|
|
"profile_id": "codex-worker-1",
|
|
"api_key": "sk-1234567890abcdef1234",
|
|
})
|
|
|
|
status_oauth = ProfileAuthManager.get_profile_status("openai-codex", "codex-orch")
|
|
status_key = ProfileAuthManager.get_profile_status("openai-codex", "codex-worker-1")
|
|
|
|
assert status_oauth["authenticated"] is True
|
|
assert status_oauth["auth_mode"] == "oauth"
|
|
assert "test***@openai.com" in status_oauth["email_masked"]
|
|
|
|
assert status_key["authenticated"] is True
|
|
assert status_key["auth_mode"] == "api_key"
|
|
assert status_key["account_id_masked"] == "sk-...1234"
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST G: ProfileAuthManager.extract_jwt_identity
|
|
# ==============================================================================
|
|
def test_g_extract_jwt_identity():
|
|
"""Test extracting email and sub from various standard and custom JWT claims."""
|
|
jwt_standard = _make_jwt({"email": "standard@gmail.com", "sub": "google-sub-123"})
|
|
jwt_openai = _make_jwt({"https://api.openai.com/profile": {"email": "custom@openai.com"}, "sub": "auth0|openai-456"})
|
|
|
|
email1, sub1 = ProfileAuthManager.extract_jwt_identity(jwt_standard)
|
|
email2, sub2 = ProfileAuthManager.extract_jwt_identity(jwt_openai)
|
|
|
|
assert email1 == "standard@gmail.com"
|
|
assert sub1 == "google-sub-123"
|
|
assert email2 == "custom@openai.com"
|
|
assert sub2 == "auth0|openai-456"
|
|
|
|
|
|
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST I: OpenCode Go API key paste with whitespace / newline stripping
|
|
# ==============================================================================
|
|
def test_i_opencode_go_paste_sanitization():
|
|
"""Verify paste sanitization strips leading/trailing newlines and whitespace."""
|
|
raw_key_with_whitespace = " \n\t opencode-sk-test-secret-999 \r\n "
|
|
cleaned = raw_key_with_whitespace.strip()
|
|
|
|
assert cleaned == "opencode-sk-test-secret-999"
|
|
|
|
is_valid, masked, models = ProfileAuthManager.verify_opencode_token(cleaned)
|
|
assert is_valid is True
|
|
assert masked == "opencode-...-999"
|
|
assert "opencode-go-3" in models
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST J: Password masking does not corrupt backing value
|
|
# ==============================================================================
|
|
def test_j_password_masking_integrity():
|
|
"""Verify password masking (show='*') retains exact backing value."""
|
|
secret = "sk-proj-actualsecretvalue123456"
|
|
auth_data = {
|
|
"provider": "openai-codex",
|
|
"profile_id": "codex-worker-2",
|
|
"auth_mode": "api_key",
|
|
"api_key": secret,
|
|
}
|
|
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-2", auth_data)
|
|
|
|
loaded = ProfileAuthManager.load_profile_auth("openai-codex", "codex-worker-2")
|
|
assert loaded["api_key"] == secret
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST K: Zero-secret logging across Codex OAuth and API key flows
|
|
# ==============================================================================
|
|
def test_k_zero_secret_logging_codex(caplog):
|
|
"""Verify raw tokens and API keys are never written to logger."""
|
|
import logging
|
|
caplog.set_level(logging.DEBUG)
|
|
|
|
session = CodexOAuthSession("codex-orch")
|
|
session._finalize_with_tokens("super_secret_access_token_9999", "super_secret_refresh_token_8888")
|
|
|
|
all_logs = " ".join([r.message for r in caplog.records])
|
|
assert "super_secret_access_token_9999" not in all_logs
|
|
assert "super_secret_refresh_token_8888" not in all_logs
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST L: Single completion lock prevents double token exchange
|
|
# ==============================================================================
|
|
def test_l_single_completion_protection():
|
|
"""Verify second finalize call does not re-save or double process."""
|
|
session = CodexOAuthSession("codex-orch")
|
|
res1 = session._finalize_with_tokens("token1", "refresh1")
|
|
assert res1 is True
|
|
assert session._is_completed is True
|
|
|
|
# Try manual input after completed
|
|
ok, msg = session.handle_manual_input("some_other_token")
|
|
assert ok is True
|
|
assert "Авторизация уже успешно завершена" in msg
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST M: Session cleanup on cancellation
|
|
# ==============================================================================
|
|
def test_m_codex_session_cleanup():
|
|
"""Verify cancel_codex_oauth_session cancels polling and cleans global store."""
|
|
session_id, url, code = start_codex_oauth("codex-spare-1")
|
|
session = get_codex_oauth_session(session_id)
|
|
assert session is not None
|
|
|
|
cancel_codex_oauth_session(session_id)
|
|
assert session.status == "cancelled"
|
|
assert get_codex_oauth_session(session_id) is None
|