From cdca9d6003f6830940b8605c7eab62da69d964d3 Mon Sep 17 00:00:00 2001 From: VolandSZ <{E-MAIL}> Date: Thu, 3 Sep 2026 00:02:36 +0300 Subject: [PATCH] feat: implement universal runner for standard and GOST TLS - Add smart two-phase TLS detection with caching in tests/utils/tls.py - Add auto-discovery for Chromium-GOST and Yandex.Browser - Add --tls-mode and --ignore-https-errors CLI options - Update browser_page context with ignore_https_errors and unified viewport - Add unit tests in tests/utils/test_tls.py - Update README.md and .env.example --- .env.example | 28 +++ README.md | 66 ++++- conftest.py | 63 +++-- .../BP01_navigation/test_bp01_navigation.py | 1 + tests/conftest.py | 73 ++++-- tests/utils/__init__.py | 3 + tests/utils/test_tls.py | 82 +++++++ tests/utils/tls.py | 229 ++++++++++++++++++ 8 files changed, 515 insertions(+), 30 deletions(-) create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_tls.py create mode 100644 tests/utils/tls.py diff --git a/.env.example b/.env.example index 9143196..8dc5f54 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,31 @@ PREFECTURE_PASSWORD= # Базовый URL портала BASE_URL=https://vet.goznak.ru + +# --------------------------------------------------------------------------- +# Режим TLS и браузер (проверяется автоматически при старте тестов) +# --------------------------------------------------------------------------- + +# Режим TLS: auto (по умолчанию), gost (принудительно ГОСТ TLS), standard (обычный TLS) +# Также можно передать флаг в pytest: pytest --tls-mode=gost +TLS_MODE=auto + +# Вариант 1 (автозапуск): путь к исполняемому файлу Chromium GOST / Яндекс.Браузер +# ОПЦИОНАЛЬНО: если браузер установлен в стандартный каталог (Program Files, AppData), +# он будет найден автоматически без заполнения этой строки. +# Скачать Chromium GOST: https://github.com/deemru/chromium-gost/releases +CHROMIUM_GOST_PATH= + +# Вариант 2: порт CDP для подключения к предзапущенному Chromium GOST +# Запуск: chrome.exe --remote-debugging-port=9222 --no-first-run +CHROMIUM_GOST_CDP_PORT=9222 + +# --------------------------------------------------------------------------- +# Кастомизация отчёта +# --------------------------------------------------------------------------- + +# Название среды тестирования (отображается в блоке Environment отчёта) +TEST_ENV=Production + +# Имя тестировщика (отображается в блоке Environment отчёта) +TESTER_NAME=QA Team diff --git a/README.md b/README.md index 255aa4b..b1a6f79 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ - [Структура проекта](#структура-проекта) - [Быстрый старт](#быстрый-старт) - [Запуск тестов](#запуск-тестов) +- [Поддержка Стандартного и ГОСТ TLS](#поддержка-стандартного-и-гост-tls) - [Создание нового теста](#создание-нового-теста) - [Добавление генератора данных (ФЛК)](#добавление-генератора-данных-флк) - [Добавление статического артефакта](#добавление-статического-артефакта) +- [Создание теста через визуальный рекордер (Codegen)](#создание-теста-через-визуальный-рекордер-codegen) --- @@ -20,15 +22,19 @@ ``` e2e_Pets/ -├── .env # Учётные данные (не в git) +├── .env # Учётные данные и настройки среды (не в git) ├── .env.example # Шаблон .env +├── conftest.py # Корневой conftest (CLI-флаги, HTML-отчёт и метаданные) ├── pytest.ini # Конфигурация pytest ├── requirements.txt # Зависимости │ └── tests/ - ├── conftest.py # Глобальные фикстуры (browser_page, bp_state) + ├── conftest.py # Глобальные фикстуры (browser_page, bp_state, _auto_browser) ├── testdata/ │ └── generators.py # Генераторы случайных данных по ФЛК + ├── utils/ + │ ├── tls.py # Детектор TLS и адаптивный менеджер браузеров + │ └── test_tls.py # Модульные тесты механизмов TLS │ ├── 01_vetdept/ # ЛК: Управление ветеринарии │ ├── conftest.py # fixtures_dir, shared_state @@ -134,6 +140,8 @@ Copy-Item .env.example .env | `--slowmo 500` | Замедление действий на 500мс (для отладки) | | `-k "BP01"` | Запустить только тесты содержащие "BP01" в имени | | `--co -q` | Только показать список тестов без запуска | +| `--tls-mode=auto` | Режим TLS: `auto` (по умолчанию — автоопределение), `gost` или `standard` | +| `--ignore-https-errors` | Игнорировать ошибки SSL сертификатов (по умолчанию включено) | ```powershell # Показать список всех тестов (без запуска) @@ -144,10 +152,64 @@ Copy-Item .env.example .env # Запустить в headless-режиме только vetdept .\venv\Scripts\pytest.exe tests/01_vetdept/ --headless --slowmo 0 + +# Принудительный запуск в режиме ГОСТ TLS (через Chromium-GOST / Яндекс.Браузер) +.\venv\Scripts\pytest.exe --tls-mode=gost + +# Принудительный запуск в режиме стандартного TLS (встроенный Chromium Playwright) +.\venv\Scripts\pytest.exe --tls-mode=standard ``` --- +## Поддержка Стандартного и ГОСТ TLS + +В проект встроен универсальный механизм запуска, который автоматически определяет, какой протокол шифрования используется на целевом портале (`BASE_URL`), и выбирает подходящий браузер. + +### Как работает автоопределение: +1. **Двухфазная детекция**: + - Выполняется тестовое SSL-рукопожатие сокета. + - Если сайт использует стандартные алгоритмы (TLS 1.2 / TLS 1.3, AES, CHACHA20 и др.), даже при наличии самоподписанного или тестового корпоративного сертификата, система распознаёт его как **Стандартный TLS** и запускает встроенный **Chromium Playwright**. + - Если сервер требует российские криптографические алгоритмы ГОСТ (ГОСТ Р 34.12-2015 «Кузнечик»/«Магма», ГОСТ Р 34.10-2012, 28147-89), которые не поддерживаются штатным OpenSSL в Python, система идентифицирует **ГОСТ TLS**. + +2. **Автопоиск браузера для ГОСТ**: + - Система автоматически сканирует стандартные каталоги установки: + - `%LOCALAPPDATA%\Chromium-Gost\Application\chrome.exe` + - `%PROGRAMFILES%\Chromium-Gost\chrome.exe` (и в `Program Files (x86)`) + - **Яндекс.Браузер** (при установленном КриптоПро CSP): `%LOCALAPPDATA%\Yandex\...`, `%PROGRAMFILES%\Yandex\...` + - Системный `PATH` (`chromium-gost`, `yandex-browser`). + - Если браузер найден в системе, тесты стартуют автоматически без необходимости вручную прописывать путь в `.env`. + +3. **Варианты запуска под ГОСТ**: + - **Автозапуск (рекомендуется)**: Установить [Chromium-GOST](https://github.com/deemru/chromium-gost/releases) или Яндекс.Браузер с КриптоПро CSP. При нестандартном расположении указать в `.env`: + ```ini + CHROMIUM_GOST_PATH=C:\CustomPath\chrome.exe + ``` + - **Подключение по CDP (если браузер уже запущен вручную)**: + ```powershell + chrome.exe --remote-debugging-port=9222 --no-first-run + ``` + Тесты автоматически подключатся к открытому браузеру на порту `9222`. + +4. **Управление режимом TLS**: + - Через аргумент CLI: + ```powershell + .\venv\Scripts\pytest.exe --tls-mode=auto # Автоопределение (по умолчанию) + .\venv\Scripts\pytest.exe --tls-mode=gost # Принудительно ГОСТ + .\venv\Scripts\pytest.exe --tls-mode=standard # Принудительно Стандартный + ``` + - Либо через `.env`: + ```ini + TLS_MODE=auto # auto | gost | standard + ``` + +5. **Отображение в HTML-отчёте**: + - В блоке **Environment** сгенерированного отчёта `html-report/report.html` фиксируются: + - Реальный режим TLS и использованный шифр (например, `Стандартный TLS (TLSv1.3, TLS_AES_256_GCM_SHA384)`). + - Тип и путь фактически запущенного браузера (`Chromium (Playwright)` / `Chromium GOST` / `Яндекс.Браузер`). + +--- + ## Создание нового теста ### Нужен новый бизнес-процесс? diff --git a/conftest.py b/conftest.py index 5486220..193b6ab 100644 --- a/conftest.py +++ b/conftest.py @@ -1,15 +1,40 @@ """ Корневой conftest.py — конфигурация на уровне всей сессии. -Здесь размещены хуки которые должны работать глобально. +Здесь размещены хуки, которые должны работать глобально. """ import os # pyrefly: ignore [missing-import] +import pytest +# pyrefly: ignore [missing-import] from dotenv import load_dotenv +from tests.utils.tls import detect_tls_mode, tls_info_key, find_gost_browser_executable + load_dotenv() BASE_URL = os.getenv("BASE_URL", "https://vet.goznak.ru") +# --------------------------------------------------------------------------- +# Регистрация CLI аргументов запуска +# --------------------------------------------------------------------------- + +def pytest_addoption(parser): + """Регистрирует дополнительные флаги командной строки для pytest.""" + parser.addoption( + "--tls-mode", + action="store", + default="auto", + choices=["auto", "gost", "standard"], + help="Режим TLS: auto (по умолчанию — автоопределение), gost (принудительно ГОСТ TLS), standard (обычный TLS).", + ) + parser.addoption( + "--ignore-https-errors", + action="store_true", + default=True, + help="Игнорировать ошибки SSL сертификатов в браузере (по умолчанию: True).", + ) + + # --------------------------------------------------------------------------- # Настройка блока Environment и заголовка HTML-отчёта # --------------------------------------------------------------------------- @@ -20,20 +45,30 @@ def pytest_sessionstart(session): pytest-html 4.x читает данные через config.stash[metadata_key] (API pytest-metadata 3.x). Наш хук изменяет именно этот stash. - - Чтобы изменить содержимое — редактируй словарь ниже. - Чтобы убрать строку — удали соответствующий ключ. - Чтобы добавить строку — добавь новую пару ключ/значение. - Порядок записей соответствует порядку в отчёте. """ config = session.config + # Получаем режим TLS из CLI аргумента или .env + cli_tls_mode = config.getoption("--tls-mode", default="auto") + force_mode = None if cli_tls_mode == "auto" else cli_tls_mode + + # Определяем режим TLS с кэшированием и сохраняем в stash сессии + is_gost, tls_desc = detect_tls_mode(BASE_URL, force_mode=force_mode) + config.stash[tls_info_key] = (is_gost, tls_desc) + + if is_gost: + gost_exec = find_gost_browser_executable() + if gost_exec and "yandex" in gost_exec.lower(): + browser_name = "Яндекс.Браузер (ГОСТ)" + else: + browser_name = "Chromium GOST" + else: + browser_name = "Chromium (Playwright)" + try: - # pytest-metadata 3.x — используется pytest-html 4.x from pytest_metadata.plugin import metadata_key metadata = config.stash[metadata_key] except (ImportError, KeyError): - # Fallback на старый API (pytest-metadata < 3.0) metadata = getattr(config, "_metadata", None) if metadata is None: return @@ -42,12 +77,12 @@ def pytest_sessionstart(session): metadata.clear() # Заполняем значимыми данными для отчёта руководителю - # ↓ Редактируй эти значения под свой проект ↓ - metadata["Проект"] = "e2e_Pets — Учёт домашних животных" - metadata["Портал"] = BASE_URL - metadata["Окружение"] = os.getenv("TEST_ENV", "Production") - metadata["Браузер"] = "Chromium (Playwright)" - metadata["Тестировщик"] = os.getenv("TESTER_NAME", "QA Team") + metadata["Проект"] = "e2e_Pets — Учёт домашних животных" + metadata["Портал"] = BASE_URL + metadata["TLS Режим"] = tls_desc + metadata["Браузер"] = browser_name + metadata["Окружение"] = os.getenv("TEST_ENV", "Production") + metadata["Тестировщик"] = os.getenv("TESTER_NAME", "QA Team") def pytest_html_report_title(report): diff --git a/tests/01_vetdept/BP01_navigation/test_bp01_navigation.py b/tests/01_vetdept/BP01_navigation/test_bp01_navigation.py index 44fb47d..8b621bc 100644 --- a/tests/01_vetdept/BP01_navigation/test_bp01_navigation.py +++ b/tests/01_vetdept/BP01_navigation/test_bp01_navigation.py @@ -1,4 +1,5 @@ import os +# pyrefly: ignore [missing-import] import pytest # pyrefly: ignore [missing-import] from playwright.sync_api import Page, expect diff --git a/tests/conftest.py b/tests/conftest.py index 56bc9f1..bff0793 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,16 +4,16 @@ import pytest # pyrefly: ignore [missing-import] from dotenv import load_dotenv # pyrefly: ignore [missing-import] -from playwright.sync_api import Page +from playwright.sync_api import Page, Playwright -# Загружаем переменные из .env (файл находится в корне проекта) +from tests.utils.tls import detect_tls_mode, launch_browser_for_tls, tls_info_key + +# Загружаем переменные из .env load_dotenv() # Базовый URL портала — берётся из .env или дефолт BASE_URL = os.getenv("BASE_URL", "https://vet.goznak.ru") - - # Учётные данные для разных ролей — берутся из .env CREDENTIALS = { "vetdept": { @@ -47,29 +47,69 @@ def _login(page: Page, role: str) -> None: f"ОШИБКА: Переменные окружения для роли '{role}' не заданы. " f"Скопируй .env.example в .env и заполни данные." ) - print(f"\n[АВТОЛОГИН] Вход под ролью: {role}") - page.goto(BASE_URL) + print(f"\n[АВТОЛОГИН] Роль: {role} | URL: {BASE_URL}") + page.goto(f"{BASE_URL}/auth") + page.wait_for_load_state("networkidle") page.get_by_role("textbox", name="Email").fill(creds["login"]) page.get_by_role("textbox", name="Введите пароль").fill(creds["password"]) page.get_by_role("button", name="Войти в аккаунт").click() - page.wait_for_selector("#app", timeout=10000) + + # Ожидаем успешного входа: переход с /auth в личный кабинет (/dashboard и т.д.) + page.wait_for_url(lambda url: "/auth" not in url, timeout=15000) + page.wait_for_load_state("networkidle") + + +# --------------------------------------------------------------------------- +# Фикстура: браузер с авто-адаптацией под тип TLS (session scope) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def _auto_browser(playwright: Playwright, browser_type_launch_args: dict, pytestconfig): + """ + Сессионный браузер, адаптированный под тип TLS целевого портала. + Применяет аргументы запуска из pytest.ini и CLI (--headed, --slowmo, --tls-mode). + """ + # Получаем результат детекции из stash или вычисляем + tls_info = pytestconfig.stash.get(tls_info_key, None) + if tls_info is None: + cli_tls_mode = pytestconfig.getoption("--tls-mode", default="auto") + force_mode = None if cli_tls_mode == "auto" else cli_tls_mode + tls_info = detect_tls_mode(BASE_URL, force_mode=force_mode) + + is_gost, tls_desc = tls_info + + browser, browser_desc = launch_browser_for_tls( + playwright=playwright, + launch_args=browser_type_launch_args, + is_gost=is_gost, + base_url=BASE_URL, + ) + print(f"\n[БРАУЗЕР] {browser_desc} | {tls_desc}") + yield browser + browser.close() # --------------------------------------------------------------------------- # Фикстура для бизнес-процессов (scope=class) # Один браузер на весь класс — авторизация выполняется один раз. -# Создаёт собственный context и page, чтобы избежать ScopeMismatch -# с function-scoped page фикстурой pytest-playwright. # --------------------------------------------------------------------------- @pytest.fixture(scope="class") -def browser_page(browser, request): +def browser_page(_auto_browser, request, pytestconfig): """ Авторизованная страница с жизненным циклом на весь класс (бизнес-процесс). Все шаги внутри класса работают в одной вкладке браузера. + + Браузер выбирается автоматически: + - Стандартный TLS -> встроенный Chromium + - ГОСТ TLS -> Chromium GOST (exe или CDP) """ role = _get_role(request) - context = browser.new_context() + ignore_https = pytestconfig.getoption("--ignore-https-errors", default=True) + context = _auto_browser.new_context( + ignore_https_errors=ignore_https, + viewport={"width": 1920, "height": 1080}, + ) page = context.new_page() _login(page, role) yield page @@ -98,19 +138,24 @@ def bp_state() -> dict: # --------------------------------------------------------------------------- # Устаревшая фикстура — оставлена для обратной совместимости -# Используй browser_page для новых тестов # --------------------------------------------------------------------------- @pytest.fixture(scope="function") -def role_page(page: Page, request): +def role_page(_auto_browser, request, pytestconfig): """ [DEPRECATED] Авторизованная страница с жизненным циклом на один тест. Для новых тестов используй browser_page (scope=class). """ role = _get_role(request) + ignore_https = pytestconfig.getoption("--ignore-https-errors", default=True) + context = _auto_browser.new_context( + ignore_https_errors=ignore_https, + viewport={"width": 1920, "height": 1080}, + ) + page = context.new_page() _login(page, role) yield page - page.context.clear_cookies() + context.close() # --------------------------------------------------------------------------- diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..75b66cd --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,3 @@ +from .tls import detect_tls_mode, launch_browser_for_tls + +__all__ = ["detect_tls_mode", "launch_browser_for_tls"] diff --git a/tests/utils/test_tls.py b/tests/utils/test_tls.py new file mode 100644 index 0000000..9904355 --- /dev/null +++ b/tests/utils/test_tls.py @@ -0,0 +1,82 @@ +""" +Тесты модуля tests/utils/tls.py: +Проверка двухфазной детекции TLS, кэширования и автопоиска ГОСТ-браузера. +""" +import ssl +from unittest.mock import patch, MagicMock +import pytest +from tests.utils.tls import detect_tls_mode, find_gost_browser_executable, _probe_tls_socket + + +def test_detect_tls_mode_http(): + is_gost, desc = detect_tls_mode("http://example.com") + assert is_gost is False + assert "HTTP" in desc + + +def test_detect_tls_mode_forced_gost(): + is_gost, desc = detect_tls_mode("https://any-site.com", force_mode="gost") + assert is_gost is True + assert "ГОСТ" in desc + + +def test_detect_tls_mode_forced_standard(): + is_gost, desc = detect_tls_mode("https://any-site.com", force_mode="standard") + assert is_gost is False + assert "Стандартный" in desc + + +def test_detect_tls_mode_real_goznak(): + # Реальный вызов к тестовому порталу + is_gost, desc = detect_tls_mode("https://vet.goznak.ru") + assert is_gost is False + assert "TLS" in desc + + +def test_probe_tls_untrusted_standard_cert(): + """ + Проверяет, что если сайт на стандартном TLS имеет недоверенный/самоподписанный сертификат, + он НЕ определяется ошибочно как ГОСТ. + """ + with patch("ssl.create_default_context") as mock_def_ctx, \ + patch("ssl._create_unverified_context") as mock_unv_ctx, \ + patch("socket.socket") as mock_socket: + + # Первая попытка с проверкой сертификата падает с SSLCertVerificationError + mock_sock_inst = MagicMock() + mock_def_ctx.return_value.wrap_socket.side_effect = ssl.SSLCertVerificationError("certificate verify failed") + + # Вторая попытка с CERT_NONE успешна + mock_unv_sock = MagicMock() + mock_unv_sock.version.return_value = "TLSv1.3" + mock_unv_sock.cipher.return_value = ("TLS_AES_256_GCM_SHA384", "TLSv1.3", 256) + mock_unv_ctx.return_value.wrap_socket.return_value.__enter__.return_value = mock_unv_sock + + is_gost, desc = _probe_tls_socket("self-signed.example.com", 443) + assert is_gost is False + assert "недоверенный сертификат" in desc + + +def test_probe_tls_real_gost_cipher_error(): + """ + Проверяет, что если handshake падает с ошибкой шифров (несовместимый протокол/шифр ГОСТ), + он определяется как ГОСТ TLS. + """ + with patch("ssl.create_default_context") as mock_def_ctx, \ + patch("ssl._create_unverified_context") as mock_unv_ctx, \ + patch("socket.socket") as mock_socket: + + mock_def_ctx.return_value.wrap_socket.side_effect = ssl.SSLError(ssl.SSL_ERROR_SSL, "UNSUPPORTED_PROTOCOL") + mock_unv_ctx.return_value.wrap_socket.side_effect = ssl.SSLError(ssl.SSL_ERROR_SSL, "NO_CIPHERS_AVAILABLE") + + is_gost, desc = _probe_tls_socket("gost.example.com", 443) + assert is_gost is True + assert "ГОСТ TLS" in desc + + +def test_find_gost_browser_executable(): + """Проверяет поиск ГОСТ-браузера.""" + path = find_gost_browser_executable() + # На данной системе должен найти установленный браузер или вернуть None + if path: + assert ("chrome.exe" in path.lower()) or ("browser.exe" in path.lower()) diff --git a/tests/utils/tls.py b/tests/utils/tls.py new file mode 100644 index 0000000..7ff62b6 --- /dev/null +++ b/tests/utils/tls.py @@ -0,0 +1,229 @@ +""" +Утилита для определения типа TLS (стандартный vs ГОСТ TLS) +и управления запуском соответствующего браузера. +""" +import functools +import os +import shutil +import socket +import ssl +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlparse + +# pyrefly: ignore [missing-import] +import pytest +# pyrefly: ignore [missing-import] +from playwright.sync_api import Browser, Playwright + +# Ключ для сохранения результатов детекции TLS в pytest stash +tls_info_key = pytest.StashKey[Tuple[bool, str]]() + + +def find_gost_browser_executable() -> Optional[str]: + """ + Выполняет автопоиск исполняемого файла Chromium-GOST или Яндекс.Браузера, + поддерживающего ГОСТ TLS через системный криптопровайдер (КриптоПро CSP). + + Возвращает: + Абсолютный путь к найденному браузеру или None. + """ + # 1. Приоритет — путь, явно заданный в .env + custom_path = os.getenv("CHROMIUM_GOST_PATH", "").strip() + if custom_path and os.path.exists(custom_path): + return os.path.abspath(custom_path) + + # 2. Список типовых путей установки в ОС Windows + local_app_data = os.environ.get("LOCALAPPDATA", "") + prog_files = os.environ.get("ProgramFiles", "") + prog_files_x86 = os.environ.get("ProgramFiles(x86)", "") + + candidates = [ + # Chromium-Gost (AppData / Program Files) + os.path.join(local_app_data, "Chromium-Gost", "Application", "chrome.exe"), + os.path.join(local_app_data, "Programs", "Chromium-Gost", "Application", "chrome.exe"), + os.path.join(prog_files, "Chromium-Gost", "chrome.exe"), + os.path.join(prog_files, "Chromium-Gost", "Application", "chrome.exe"), + os.path.join(prog_files_x86, "Chromium-Gost", "chrome.exe"), + os.path.join(prog_files_x86, "Chromium-Gost", "Application", "chrome.exe"), + # Яндекс.Браузер (поддерживает ГОСТ TLS при наличии КриптоПро CSP) + os.path.join(local_app_data, "Yandex", "YandexBrowser", "Application", "browser.exe"), + os.path.join(prog_files, "Yandex", "YandexBrowser", "Application", "browser.exe"), + os.path.join(prog_files_x86, "Yandex", "YandexBrowser", "Application", "browser.exe"), + # Linux / macOS пути + "/opt/chromium-gost/chrome", + "/usr/bin/chromium-gost", + "/Applications/Chromium-Gost.app/Contents/MacOS/Chromium-Gost", + "/Applications/Yandex.app/Contents/MacOS/Yandex", + ] + + for path in candidates: + if path and os.path.exists(path): + return os.path.abspath(path) + + # 3. Поиск в системном PATH + for name in ("chromium-gost", "chrome-gost", "yandex-browser"): + found = shutil.which(name) + if found: + return os.path.abspath(found) + + return None + + +def _probe_tls_socket(host: str, port: int) -> Tuple[bool, str]: + """ + Выполняет двухфазную проверку TLS сокета: + Фаза 1: стандартная проверка с валидацией цепочки сертификатов. + Фаза 2: если ошибка связана с недоверенным/тестовым сертификатом, + проверяется handshake без валидации сертификата (CERT_NONE). + Если он успешен — это стандартный TLS (не ГОСТ!). + Если же handshake не может договориться по ciphers/protocols — это ГОСТ TLS. + """ + # Фаза 1: Обычный контекст с валидацией + ctx = ssl.create_default_context() + try: + with ctx.wrap_socket(socket.socket(), server_hostname=host) as s: + s.settimeout(5) + s.connect((host, port)) + cipher = s.cipher() + cipher_name = cipher[0] if cipher else "Unknown" + return False, f"Стандартный TLS ({s.version()}, {cipher_name})" + except ssl.SSLCertVerificationError as e: + # Сертификат недоверен / самоподписан, но шифрование может быть стандартным! + # Проверяем Фазу 2 с CERT_NONE + try: + unverified_ctx = ssl._create_unverified_context() + with unverified_ctx.wrap_socket(socket.socket(), server_hostname=host) as s: + s.settimeout(5) + s.connect((host, port)) + cipher = s.cipher() + cipher_name = cipher[0] if cipher else "Unknown" + return False, f"Стандартный TLS (недоверенный сертификат: {s.version()}, {cipher_name})" + except ssl.SSLError: + pass + return True, f"ГОСТ TLS (ошибка верификации и шифра: {e})" + except ssl.SSLError as e: + reason = getattr(e, "reason", str(e)) + # Пробуем без проверки сертификата на случай, если причина в самоподписанном сертификате + try: + unverified_ctx = ssl._create_unverified_context() + with unverified_ctx.wrap_socket(socket.socket(), server_hostname=host) as s: + s.settimeout(5) + s.connect((host, port)) + cipher = s.cipher() + cipher_name = cipher[0] if cipher else "Unknown" + return False, f"Стандартный TLS (без валидации: {s.version()}, {cipher_name})" + except Exception: + # Стандартный OpenSSL не понимает ГОСТ ciphers / протокол + return True, f"ГОСТ TLS (OpenSSL handshake: {reason})" + except Exception as e: + # Эвристика: если сервис paws.* недоступен по таймауту/сети, но является целевым ГОСТ-контуром + if "paws" in host.lower(): + return True, f"ГОСТ TLS (домен {host}, fallback: {e})" + return False, f"Стандартный TLS (fallback: {e})" + + +@functools.lru_cache(maxsize=32) +def detect_tls_mode(url: str, force_mode: Optional[str] = None) -> Tuple[bool, str]: + """ + Определяет тип TLS соединения для целевого URL с кэшированием результатов. + + Возвращает: + (is_gost: bool, description: str) + + Приоритет: + 1. Аргумент force_mode (например, переданный из CLI pytest --tls-mode) + 2. Переменная USE_GOST или TLS_MODE в .env + 3. Двухфазная проверка через сокет (handshake): + - Успешное подключение стандартным OpenSSL -> Стандартный TLS (False) + - Ошибка шифрования (UNSUPPORTED_PROTOCOL, NO_CIPHERS_AVAILABLE и т.д.) -> ГОСТ TLS (True) + - Домен содержит "paws" при таймауте -> ГОСТ TLS (True) + """ + # 1. Приоритет — явный force_mode + mode = (force_mode or os.getenv("TLS_MODE") or os.getenv("USE_GOST") or "").strip().lower() + if mode in ("gost", "true", "1", "yes", "on"): + return True, "Принудительно (ГОСТ TLS)" + if mode in ("standard", "regular", "false", "0", "no", "off"): + return False, "Принудительно (Стандартный TLS)" + + # 2. Проверка протокола + parsed = urlparse(url) + if parsed.scheme == "http": + return False, "HTTP (без TLS)" + + host = parsed.hostname or url + port = parsed.port or 443 + + # 3. Проверка сокета + return _probe_tls_socket(host, port) + + +def launch_browser_for_tls( + playwright: Playwright, + launch_args: Dict[str, Any], + is_gost: bool, + base_url: str +) -> Tuple[Browser, str]: + """ + Запускает подходящий браузер в зависимости от типа TLS. + + Возвращает: + (browser_instance, description_string) + + Логика: + - Стандартный TLS: запускает встроенный Chromium Playwright с аргументами launch_args. + - ГОСТ TLS: + 1. Ищет путь к Chromium-GOST / Яндекс.Браузеру через find_gost_browser_executable(). + 2. Если найден — запускает с заданными launch_args. + 3. Если не найден — пробует подключиться к запущенному браузеру по CDP (CHROMIUM_GOST_CDP_PORT). + 4. Если оба варианта недоступны — выдаёт информативную ошибку с подробными шагами решения. + """ + cdp_port = os.getenv("CHROMIUM_GOST_CDP_PORT", "9222").strip() + + if not is_gost: + # Стандартный TLS + browser = playwright.chromium.launch(**launch_args) + return browser, "Chromium (Playwright)" + + # ГОСТ TLS: Вариант 1 — автопоиск и запуск по executable_path + gost_path = find_gost_browser_executable() + if gost_path and os.path.exists(gost_path): + try: + browser = playwright.chromium.launch( + executable_path=gost_path, + **launch_args + ) + browser_name = "Яндекс.Браузер" if "yandex" in gost_path.lower() else "Chromium GOST" + return browser, f"{browser_name} ({gost_path})" + except Exception as e: + print(f"\n[GOST] Предупреждение: ошибка прямого запуска ({gost_path}): {e}") + print("[GOST] Пробуем подключение по CDP...") + + # ГОСТ TLS: Вариант 2 — подключение по CDP + cdp_url = f"http://localhost:{cdp_port}" + try: + connect_args: Dict[str, Any] = {"timeout": 4000} + if "slow_mo" in launch_args: + connect_args["slow_mo"] = launch_args["slow_mo"] + browser = playwright.chromium.connect_over_cdp(cdp_url, **connect_args) + return browser, f"Chromium GOST (CDP порт {cdp_port})" + except Exception: + pass + + # Ничего не подошло — информативное сообщение об ошибке + pytest.fail( + f"\n{'=' * 70}\n" + f"ОШИБКА: Обнаружен ГОСТ TLS для адреса: {base_url}\n" + f"Стандартный встроенный Chromium не поддерживает криптоалгоритмы ГОСТ.\n\n" + f"Для запуска тестов выберите один из вариантов:\n\n" + f"Вариант 1 (Автозапуск — рекомендуемый):\n" + f" 1. Скачайте Chromium GOST:\n" + f" https://github.com/deemru/chromium-gost/releases\n" + f" 2. Распакуйте/установите его (он будет найден автоматически), либо укажите в .env:\n" + f" CHROMIUM_GOST_PATH=C:\\Path\\To\\chromium-gost\\chrome.exe\n" + f" * Примечание: также поддерживается установленный Яндекс.Браузер с КриптоПро CSP.\n\n" + f"Вариант 2 (Подключение по CDP):\n" + f" 1. Запустите Chromium GOST вручную с флагом отладки:\n" + f" chrome.exe --remote-debugging-port={cdp_port} --no-first-run\n" + f" 2. Запустите pytest повторно\n" + f"{'=' * 70}" + )