На машине владельца задана ANTHROPIC_BASE_URL=https://api.anthropic.com, и из-за неё test_claude_health_check_real_probe падал: адаптер строил ".../models" вместо ".../v1/models". Код при этом верен — подставлялось значение из окружения вместо умолчания. Проверено: со снятой переменной 486 passed, с заданной — одно падение. Набор обязан давать одинаковый результат на любой машине; это то же требование герметичности, ради которого делался A33. Добавлена автоматическая фикстура, убирающая восемь переменных *_BASE_URL на время каждого теста. После правки прогон с заданной переменной даёт 486 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
4.8 KiB
Python
125 lines
4.8 KiB
Python
"""Pytest configuration and global hermetic test isolation fixtures.
|
||
|
||
Enforces:
|
||
1. Zero modification to real user credentials or router_profiles.yaml.
|
||
2. Complete filesystem sandboxing in temporary directory via HERMES_HOME.
|
||
3. Offline execution for default test runs (network / live require explicit -m markers).
|
||
4. No accidental connects to local llama.cpp (8081/8082) or non-loopback hosts.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import socket
|
||
import sys
|
||
from pathlib import Path
|
||
import pytest
|
||
|
||
REPO_SRC = Path(__file__).resolve().parent.parent / "src"
|
||
if str(REPO_SRC) not in sys.path or sys.path[0] != str(REPO_SRC):
|
||
sys.path.insert(0, str(REPO_SRC))
|
||
|
||
BLOCKED_INFERENCE_PORTS = {8081, 8082}
|
||
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "::"}
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def isolate_hermes_environment(tmp_path, monkeypatch):
|
||
"""Automatically sandbox all file I/O to a temporary HERMES_HOME directory."""
|
||
temp_hermes = tmp_path / "hermes_test_home"
|
||
temp_hermes.mkdir(parents=True, exist_ok=True)
|
||
monkeypatch.setenv("HERMES_HOME", str(temp_hermes))
|
||
|
||
# Isolate all provider profile dirs
|
||
(temp_hermes / "agy_profiles").mkdir(exist_ok=True)
|
||
(temp_hermes / "codex_profiles").mkdir(exist_ok=True)
|
||
(temp_hermes / "opengo_profiles").mkdir(exist_ok=True)
|
||
(temp_hermes / "claude_profiles").mkdir(exist_ok=True)
|
||
(temp_hermes / "grok_profiles").mkdir(exist_ok=True)
|
||
(temp_hermes / "logs").mkdir(exist_ok=True)
|
||
|
||
yield temp_hermes
|
||
|
||
|
||
def _socket_host_port(address) -> tuple[str | None, int | None]:
|
||
if isinstance(address, tuple) and len(address) >= 2:
|
||
host = address[0]
|
||
port = address[1]
|
||
if isinstance(host, bytes):
|
||
host = host.decode("utf-8", errors="replace")
|
||
try:
|
||
return str(host), int(port)
|
||
except (TypeError, ValueError):
|
||
return str(host), None
|
||
return None, None
|
||
|
||
|
||
def _reject_hermetic_connect(address) -> None:
|
||
host, port = _socket_host_port(address)
|
||
if port in BLOCKED_INFERENCE_PORTS:
|
||
raise RuntimeError(
|
||
f"hermetic tests must not contact local inference at {host}:{port}; "
|
||
"mock check_local_servers / urllib.request.urlopen"
|
||
)
|
||
if host and host not in LOOPBACK_HOSTS and not host.startswith("127."):
|
||
raise RuntimeError(
|
||
f"hermetic tests must not open network connections to {host}:{port}; "
|
||
"mark the test live/network or mock the call"
|
||
)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def block_external_network_in_hermetic_tests(request, monkeypatch):
|
||
"""Fail fast instead of hanging on llama.cpp or cloud APIs."""
|
||
if request.node.get_closest_marker("live") or request.node.get_closest_marker("network"):
|
||
yield
|
||
return
|
||
|
||
real_connect = socket.socket.connect
|
||
real_connect_ex = socket.socket.connect_ex
|
||
real_create_connection = socket.create_connection
|
||
|
||
def guarded_connect(self, address):
|
||
_reject_hermetic_connect(address)
|
||
return real_connect(self, address)
|
||
|
||
def guarded_connect_ex(self, address):
|
||
_reject_hermetic_connect(address)
|
||
return real_connect_ex(self, address)
|
||
|
||
def guarded_create_connection(address, *args, **kwargs):
|
||
_reject_hermetic_connect(address)
|
||
return real_create_connection(address, *args, **kwargs)
|
||
|
||
monkeypatch.setattr(socket.socket, "connect", guarded_connect)
|
||
monkeypatch.setattr(socket.socket, "connect_ex", guarded_connect_ex)
|
||
monkeypatch.setattr(socket, "create_connection", guarded_create_connection)
|
||
yield
|
||
|
||
|
||
def pytest_configure(config):
|
||
config.addinivalue_line("markers", "ui: mark test as requiring UI environment")
|
||
|
||
# Переменные окружения провайдеров не должны протекать в тесты.
|
||
#
|
||
# На машине владельца задана ANTHROPIC_BASE_URL=https://api.anthropic.com, и
|
||
# из-за неё test_claude_health_check_real_probe падал: адаптер строил
|
||
# ".../models" вместо ".../v1/models", хотя код верен, а подставилось значение
|
||
# из окружения. Набор обязан давать одинаковый результат на любой машине —
|
||
# это то же требование герметичности, ради которого делался A33.
|
||
_PROVIDER_BASE_URL_VARS = (
|
||
"ANTHROPIC_BASE_URL",
|
||
"CODEX_BASE_URL",
|
||
"DEEPSEEK_BASE_URL",
|
||
"LOCAL_LLM_BASE_URL",
|
||
"NVIDIA_BASE_URL",
|
||
"OLLAMA_BASE_URL",
|
||
"OPENCODE_GO_BASE_URL",
|
||
"OPENROUTER_BASE_URL",
|
||
)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _isolate_provider_base_urls(monkeypatch):
|
||
"""Убрать адреса провайдеров из окружения на время каждого теста."""
|
||
for name in _PROVIDER_BASE_URL_VARS:
|
||
monkeypatch.delenv(name, raising=False)
|