All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 2m45s
Deploy Trade-In / build-browser (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 2m29s
301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""test_server_no_proxy_refusal.py — #2616 шаг 1: не идти напрямую с IP сервера.
|
||
|
||
tradein-browser (per-provider camoufox) раньше в прод-контейнере, если ни тело
|
||
запроса, ни SCRAPER_PROXY_URL не несли proxy, лениво запускал camoufox БЕЗ
|
||
proxy — прямое подключение с IP сервера. #2616 шаг 2 удалил мёртвые per-provider
|
||
переменные (AVITO_PROXY_URL/BROWSER_PROXY_* и т.д., 407/connection refused,
|
||
#2613) — SCRAPER_PROXY_URL остался единственным источником. Этот файл фиксирует
|
||
контракт (`_no_live_proxy` + `IS_PROD` guard в
|
||
fetch_handler/fetch_json_handler/login_handler).
|
||
|
||
Проверяет:
|
||
1. `_no_live_proxy` — pure-function поведение (override / env приоритеты).
|
||
2. prod + нет override + нет env → 503 с распознаваемой причиной, `_ensure_browser`
|
||
(и, соответственно, `_launch_browser`) НЕ вызывается вовсе — camoufox не
|
||
стартует без прокси.
|
||
3. dev (или явный IS_PROD=False) + то же самое → прежнее поведение (lazy launch
|
||
без прокси легитимен для локальной разработки).
|
||
4. prod + proxy присутствует (override ИЛИ env) → guard не триггерит, поведение
|
||
без изменений (это НЕ случай отказа).
|
||
5. Текст отказа отличим от "browser unavailable (proxy may be down)" (launch
|
||
failed на мёртвом-но-заданном прокси) — разные причины, разный текст.
|
||
|
||
camoufox НЕ запускается: `_ensure_browser` мокается (см. test_server_proxy_override.py).
|
||
Хендлер вызывается напрямую (make_mocked_request не поднимает сокет).
|
||
|
||
Запуск (из tradein-mvp/browser/)::
|
||
|
||
python -m pytest test_server_no_proxy_refusal.py -q
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import importlib.util
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from aiohttp.test_utils import make_mocked_request
|
||
|
||
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
|
||
assert _spec is not None and _spec.loader is not None
|
||
server = importlib.util.module_from_spec(_spec)
|
||
_spec.loader.exec_module(server)
|
||
|
||
|
||
def _json_body(response: Any) -> dict[str, Any]:
|
||
return json.loads(response.body.decode())
|
||
|
||
|
||
async def _coro(value: Any) -> Any:
|
||
return value
|
||
|
||
|
||
def _make_request(path: str, body: dict[str, Any]) -> Any:
|
||
request = make_mocked_request("POST", path)
|
||
request.json = lambda: _coro(body) # type: ignore[method-assign]
|
||
return request
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Чистое per-provider состояние + НЕТ live env-прокси ни для одного провайдера.
|
||
|
||
Явно чистим SCRAPER_PROXY_URL (единственная переменная, которую `_provider_proxy`
|
||
читает, #2616 шаг 2) — тест не должен зависеть от того, что реально задано в
|
||
shell/CI-раннере.
|
||
"""
|
||
monkeypatch.setattr(server, "_browsers", {})
|
||
monkeypatch.setattr(server, "_browser_cms", {})
|
||
monkeypatch.setattr(server, "_page_counters", {})
|
||
monkeypatch.setattr(server, "_locks", {})
|
||
monkeypatch.setattr(server, "_retry_tasks", {})
|
||
monkeypatch.setattr(server, "_last_goto_at", {})
|
||
monkeypatch.setattr(server, "_launched_proxy", {})
|
||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||
|
||
|
||
def _forbid_ensure_browser(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||
"""Мок `_ensure_browser`, падающий AssertionError на любом вызове — falsification:
|
||
если guard регрессирует (перестаёт отказывать ДО lock/launch), тест ловит это
|
||
несовпадением типа исключения, а не просто другим статус-кодом.
|
||
"""
|
||
calls: list[str] = []
|
||
|
||
async def _boom(provider: str, proxy_override: str | None = None) -> bool:
|
||
calls.append(provider)
|
||
raise AssertionError("_ensure_browser must NOT be called — no proxy configured, prod")
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _boom)
|
||
return calls
|
||
|
||
|
||
# ── _no_live_proxy: pure-function поведение ─────────────────────────────────────
|
||
|
||
|
||
def test_no_live_proxy_true_without_override_and_env() -> None:
|
||
assert server._no_live_proxy("avito", None) is True
|
||
|
||
|
||
def test_no_live_proxy_false_with_override() -> None:
|
||
assert server._no_live_proxy("avito", "http://pool:8080") is False
|
||
|
||
|
||
def test_no_live_proxy_false_with_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080")
|
||
assert server._no_live_proxy("avito", None) is False
|
||
|
||
|
||
# ── fetch_handler: prod-отказ, никакого launch'а ────────────────────────────────
|
||
|
||
|
||
def test_fetch_handler_prod_refuses_without_launch_attempt(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(server, "IS_PROD", True)
|
||
calls = _forbid_ensure_browser(monkeypatch)
|
||
|
||
response = asyncio.run(
|
||
server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"}))
|
||
)
|
||
|
||
assert response.status == 503
|
||
assert calls == [] # _ensure_browser НЕ вызывался вовсе
|
||
body = _json_body(response)
|
||
assert "no proxy configured" in body["error"]
|
||
|
||
|
||
def test_fetch_json_handler_prod_refuses_without_launch_attempt(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(server, "IS_PROD", True)
|
||
calls = _forbid_ensure_browser(monkeypatch)
|
||
|
||
response = asyncio.run(
|
||
server.fetch_json_handler(
|
||
_make_request("/fetch-json", {"url": "https://www.avito.ru/api/x", "source": "avito"})
|
||
)
|
||
)
|
||
|
||
assert response.status == 503
|
||
assert calls == []
|
||
body = _json_body(response)
|
||
assert "no proxy configured" in body["error"]
|
||
|
||
|
||
def test_login_handler_prod_refuses_without_launch_attempt(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(server, "IS_PROD", True)
|
||
calls = _forbid_ensure_browser(monkeypatch)
|
||
|
||
response = asyncio.run(
|
||
server.login_handler(
|
||
_make_request(
|
||
"/login",
|
||
{
|
||
"url": "https://www.cian.ru/login",
|
||
"email": "a@b.ru",
|
||
"password": "x",
|
||
"email_selector": "#e",
|
||
"password_selector": "#p",
|
||
"submit_selector": "#s",
|
||
"success_cookie": "sid",
|
||
},
|
||
)
|
||
)
|
||
)
|
||
|
||
assert response.status == 503
|
||
assert calls == []
|
||
body = _json_body(response)
|
||
assert "no proxy configured" in body["error"]
|
||
|
||
|
||
# ── dev: прежнее поведение (lazy launch без прокси легитимен) ──────────────────
|
||
|
||
|
||
def test_fetch_handler_dev_still_attempts_launch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""IS_PROD=False (dev) + нет прокси вообще → guard НЕ триггерит, _ensure_browser
|
||
вызывается как раньше (dev/no-op direct connection — легитимно для локалки).
|
||
"""
|
||
monkeypatch.setattr(server, "IS_PROD", False)
|
||
calls: list[tuple[str, str | None]] = []
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
calls.append((provider, proxy_override))
|
||
return True
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
server._browsers["avito"] = object() # чтобы дальше не падать на _do_fetch
|
||
|
||
async def _fake_do_fetch(*_a: Any, **_kw: Any) -> str:
|
||
return "<ok>"
|
||
|
||
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
|
||
|
||
response = asyncio.run(
|
||
server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"}))
|
||
)
|
||
|
||
assert response.status == 200
|
||
assert calls == [("avito", None)] # launch БЫЛ атакован — dev-путь не блокирует
|
||
|
||
|
||
# ── prod + proxy присутствует (override ИЛИ env) → guard не триггерит ──────────
|
||
|
||
|
||
def test_fetch_handler_prod_with_env_proxy_unaffected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(server, "IS_PROD", True)
|
||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080") # "живой" с т.з. guard'а
|
||
calls: list[tuple[str, str | None]] = []
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
calls.append((provider, proxy_override))
|
||
return True
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
server._browsers["avito"] = object()
|
||
|
||
async def _fake_do_fetch(*_a: Any, **_kw: Any) -> str:
|
||
return "<ok>"
|
||
|
||
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
|
||
|
||
response = asyncio.run(
|
||
server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"}))
|
||
)
|
||
|
||
assert response.status == 200
|
||
assert calls == [("avito", None)] # guard не заблокировал — env-прокси "есть"
|
||
|
||
|
||
def test_fetch_handler_prod_with_body_override_unaffected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(server, "IS_PROD", True)
|
||
calls: list[tuple[str, str | None]] = []
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
calls.append((provider, proxy_override))
|
||
return True
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
server._browsers["avito"] = object()
|
||
|
||
async def _fake_do_fetch(*_a: Any, **_kw: Any) -> str:
|
||
return "<ok>"
|
||
|
||
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
|
||
|
||
response = asyncio.run(
|
||
server.fetch_handler(
|
||
_make_request(
|
||
"/fetch",
|
||
{
|
||
"url": "https://www.avito.ru/x",
|
||
"proxy": "http://pool:8080",
|
||
"proxy_kind": "http",
|
||
},
|
||
)
|
||
)
|
||
)
|
||
|
||
assert response.status == 200
|
||
assert calls == [("avito", "http://pool:8080")]
|
||
|
||
|
||
# ── распознаваемость: отказ ≠ "browser unavailable (proxy may be down)" ────────
|
||
|
||
|
||
def test_refusal_message_distinguishable_from_launch_failure(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
""""нет прокси вообще" (наша инфраструктура) — другой текст, чем "browser
|
||
unavailable (proxy may be down)" (launch упал на мёртвом-но-заданном прокси).
|
||
"""
|
||
monkeypatch.setattr(server, "IS_PROD", True)
|
||
_forbid_ensure_browser(monkeypatch)
|
||
|
||
no_proxy_resp = asyncio.run(
|
||
server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"}))
|
||
)
|
||
no_proxy_text = _json_body(no_proxy_resp)["error"]
|
||
|
||
# launch failure (дохлый-но-заданный env) — существующий, другой путь.
|
||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://dead:8080")
|
||
|
||
async def _fails_launch(provider: str, proxy_override: str | None = None) -> bool:
|
||
return False # camoufox launch упал (InvalidProxy) — существующая семантика
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _fails_launch)
|
||
launch_failed_resp = asyncio.run(
|
||
server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"}))
|
||
)
|
||
launch_failed_text = _json_body(launch_failed_resp)["error"]
|
||
|
||
assert no_proxy_text != launch_failed_text
|
||
assert "no proxy configured" in no_proxy_text
|
||
assert "browser unavailable" in launch_failed_text
|