gendesign/tradein-mvp/backend/tests/services/test_proxy_rotation.py
bot-backend bed2b7bca9
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m46s
fix(tradein/proxy): pin ASocks rotate_url host — не слать токен на чужой прокси (#2600)
scrape_proxies.rotate_url колонка неоднородна: прод несёт и mobileproxy
changeip-ссылки (id 3/4/5), и ASocks-ссылки (id 1/9/10/11). Без явной проверки
хоста Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы на чужой провайдер —
security review PR #2611. Добавлен ALLOWED_ROTATE_HOST-пиннинг (https-only,
хост == api.asocks.com) ДО HTTP-вызова; несовпадение — отказ, не безголовый
запрос без Authorization (смысл ручной ротации — конкретный провайдер).

Заодно: класс исключения (не секрет) в note сетевой ошибки — отличить
ConnectError от ReadTimeout; расширено leak-покрытие на текст log/Sentry
сообщений (не только reason/note).
2026-08-01 22:12:52 +03:00

509 lines
23 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.

"""Offline-тесты ротации exit-IP ASocks (#2600 п.5).
Покрытие БЕЗ live-сети/БД: httpx.AsyncClient подменён предсказуемым фейком,
FakeSession эмулирует scrape_proxies (одна строка) + scrape_proxy_rotations
(append-only список), pytest-asyncio (asyncio_mode=auto, см. pyproject.toml).
- rotate_url пуст → «не поддерживается», НЕ ошибка, HTTP не дёргается.
- ASOCKS_API_TOKEN не задан → внятный отказ, HTTP не дёргается.
- 4-я попытка за сутки отклоняется БЕЗ обращения к API (лимит 3/сутки).
- Успешная ротация пишет запись в scrape_proxy_rotations (success=True).
- 401 → logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг),
аудит-запись пишется, но НЕ считается против суточного лимита.
- Токен не появляется ни в RotationResult.reason, ни в note аудит-записи, ни в
тексте log-сообщений (caplog.getMessage()), ни в тексте, ушедшем в Sentry —
ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success).
- rotate_url на ЧУЖОМ хосте (не ALLOWED_ROTATE_HOST) → отказ ДО HTTP-вызова —
scrape_proxies.rotate_url колонка неоднородна (несёт и mobileproxy changeip-
ссылки), наш ASOCKS_API_TOKEN не должен уйти на них (security review PR #2611).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
import pytest
from app.services import proxy_rotation
SECRET_TOKEN = "asocks-super-secret-token-must-never-leak-1a2b3c"
# ── stateful fakes ────────────────────────────────────────────────────────────
class _FakeResult:
def __init__(self, rows: list[dict[str, Any]]):
self._rows = rows
def mappings(self) -> _FakeResult:
return self
def fetchone(self) -> dict[str, Any] | None:
return self._rows[0] if self._rows else None
class FakeSession:
"""Эмуляция Session: одна строка scrape_proxies + append-only
scrape_proxy_rotations, интерпретирует SQL по ключевым фрагментам (тот же
паттерн, что tests/services/test_proxy_pool.py)."""
def __init__(
self,
proxy_row: dict[str, Any] | None,
rotations: list[dict[str, Any]] | None = None,
):
self.proxy_row = proxy_row
self.rotations: list[dict[str, Any]] = rotations or []
self.commits = 0
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
sql = str(stmt)
p = params or {}
if "SELECT id, rotate_url FROM scrape_proxies" in sql:
if self.proxy_row is None or self.proxy_row["id"] != p["id"]:
return _FakeResult([])
return _FakeResult([dict(self.proxy_row)])
if "SELECT count(*) AS n" in sql and "scrape_proxy_rotations" in sql:
cutoff = datetime.now(UTC) - timedelta(hours=24)
n = sum(
1
for r in self.rotations
if r["proxy_id"] == p["proxy_id"]
and r["rotated_at"] > cutoff
and r["http_status"] is not None
and r["http_status"] != 401
)
return _FakeResult([{"n": n}])
if "INSERT INTO scrape_proxy_rotations" in sql:
self.rotations.append(
{
"proxy_id": p["proxy_id"],
"success": p["success"],
"http_status": p["http_status"],
"note": p["note"],
"rotated_at": datetime.now(UTC),
}
)
return _FakeResult([])
raise AssertionError(f"unhandled SQL: {sql}")
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
pass
class _FakeResponse:
def __init__(self, status_code: int, json_data: dict[str, Any] | None):
self.status_code = status_code
self._json_data = json_data
def json(self) -> dict[str, Any]:
if self._json_data is None:
raise ValueError("no json body")
return self._json_data
def _fake_async_client(
*,
response: tuple[int, dict[str, Any] | None] | None,
exception: Exception | None,
):
"""Строит замену httpx.AsyncClient, никогда не бьющую в реальную сеть.
Ровно один из (response, exception) задан. calls накапливает (url, headers)
каждого post() — тест проверяет по ним, был ли вообще HTTP-вызов.
"""
calls: list[dict[str, Any]] = []
class _FakeClientImpl:
def __init__(self, timeout: float | None = None) -> None:
self.timeout = timeout
async def __aenter__(self) -> _FakeClientImpl:
return self
async def __aexit__(self, *exc: object) -> bool:
return False
async def post(self, url: str, headers: dict[str, str] | None = None) -> _FakeResponse:
calls.append({"url": url, "headers": headers or {}})
if exception is not None:
raise exception
assert response is not None
status, body = response
return _FakeResponse(status, body)
return _FakeClientImpl, calls
def _no_http_allowed():
"""httpx.AsyncClient-заглушка, падающая AssertionError при любом post() —
для сценариев, где HTTP до провайдера дойти НЕ должно."""
class _ForbiddenClient:
def __init__(self, timeout: float | None = None) -> None:
pass
async def __aenter__(self) -> _ForbiddenClient:
return self
async def __aexit__(self, *exc: object) -> bool:
return False
async def post(self, *a: object, **kw: object) -> None:
raise AssertionError("HTTP call must NOT happen for this scenario")
return _ForbiddenClient
_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip"
# (name, rotate_url, response=(status, json_body)|None, exception|None) — ровно один
# из response/exception задан, либо оба None (локальный отказ, HTTP не идёт).
_LogScenario = tuple[str, str | None, tuple[int, dict[str, Any] | None] | None, Exception | None]
def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]:
return {"id": 1, "rotate_url": rotate_url}
def _quota_rows(proxy_id: int, n: int, *, http_status: int = 200) -> list[dict[str, Any]]:
now = datetime.now(UTC)
return [
{
"proxy_id": proxy_id,
"success": http_status < 400,
"http_status": http_status,
"note": None,
"rotated_at": now - timedelta(minutes=i),
}
for i in range(n)
]
# ── no rotate_url → not an error ────────────────────────────────────────────
async def test_no_rotate_url_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row(rotate_url=None))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None
assert "rotat" in result.reason.lower() # human-readable, not a crash
assert db.rotations == [] # ничего не писалось — попытки не было
# ── host pinning (security review PR #2611) ─────────────────────────────────
#
# scrape_proxies.rotate_url колонка неоднородна: прод сейчас несёт mobileproxy
# changeip-ссылки (id 3/4/5) БОК О БОК с ASocks-ссылками (id 1/9/10/11, миграция
# 199). Без host-пиннинга наш Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы
# на чужой провайдер.
async def test_rotate_url_on_foreign_host_refused_before_http_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
foreign_url = "https://changeip.mobileproxy.space/?proxy_key=mobileproxy-own-secret"
db = FakeSession(_proxy_row(rotate_url=foreign_url))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None
# _no_http_allowed() would have raised AssertionError from within rotate_proxy
# if the code had tried an HTTP call (i.e. sent our token) — reaching this
# line means it refused first. Belt-and-suspenders: no audit row either
# (this is a local rejection, same as no-rotate_url/no-token/limit).
assert db.rotations == []
assert SECRET_TOKEN not in result.reason
async def test_allowed_host_case_insensitive_still_proceeds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Хост сверяется без учёта регистра (urlparse().hostname лоуеркейзит) — тот
же ALLOWED_ROTATE_HOST в другом регистре ДОЛЖЕН проходить, иначе пиннинг
превратился бы в ложный отказ на легитимном rotate_url."""
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, calls = _fake_async_client(response=(200, {"ip": "1.2.3.4"}), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row(rotate_url="https://API.ASOCKS.COM/unlimited-proxy/1/refresh-ip"))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is True
assert len(calls) == 1
async def test_allowed_host_over_plain_http_is_refused(monkeypatch: pytest.MonkeyPatch) -> None:
"""http:// (не https://) на тот же хост — отказ (защита от даунгрейда
транспорта, которым Authorization ушёл бы в открытом виде)."""
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row(rotate_url="http://api.asocks.com/unlimited-proxy/1/refresh-ip"))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert db.rotations == []
# ── missing token → neutral refusal, no crash ───────────────────────────────
async def test_missing_token_is_neutral_refusal(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", "")
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None
assert db.rotations == []
# ── daily limit ──────────────────────────────────────────────────────────────
async def test_fourth_attempt_today_rejected_without_api_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
# 3 quota-consuming попытки уже сегодня (успешные 200 — засчитываются).
db = FakeSession(_proxy_row(), _quota_rows(1, proxy_rotation.DAILY_ROTATION_LIMIT))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert "limit" in (result.reason or "").lower() or "лимит" in (result.reason or "").lower()
assert result.rotations_remaining_today == 0
# _no_http_allowed() would have raised AssertionError from within rotate_proxy
# if the code had tried an HTTP call — reaching here means it didn't.
assert len(db.rotations) == proxy_rotation.DAILY_ROTATION_LIMIT # ничего нового не дописано
# ── success writes history ──────────────────────────────────────────────────
async def test_successful_rotation_writes_history_row(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, calls = _fake_async_client(response=(200, {"ip": "9.9.9.9"}), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is True
assert result.new_ip == "9.9.9.9"
assert result.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT - 1
assert len(calls) == 1
assert calls[0]["headers"]["Authorization"] == f"Bearer {SECRET_TOKEN}"
assert len(db.rotations) == 1
row = db.rotations[0]
assert row["success"] is True
assert row["http_status"] == 200
assert db.commits >= 1
# ── 401 → loud failure ───────────────────────────────────────────────────────
async def test_401_logs_error_and_alerts_monitoring_excluded_from_quota(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, calls = _fake_async_client(
response=(401, {"success": False, "message": "Unauthenticated"}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
sentry_calls: list[tuple[str, str | None]] = []
monkeypatch.setattr(
"sentry_sdk.capture_message",
lambda msg, level=None: sentry_calls.append((msg, level)),
)
db = FakeSession(_proxy_row())
with caplog.at_level(logging.ERROR):
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert len(calls) == 1 # запрос реально ушёл
# громкий отказ: и лог, и мониторинг — не молчаливая остановка
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
assert any("401" in r.getMessage() for r in error_records)
assert len(sentry_calls) == 1
assert sentry_calls[0][1] == "error"
# аудит записан, но 401 НЕ считается против суточного лимита (см. модуль
# docstring: auth-отсев до провайдера, лимит на его стороне не тратится).
assert len(db.rotations) == 1
assert db.rotations[0]["http_status"] == 401
assert db.rotations[0]["success"] is False
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
second = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
# 401 не съел лимит — снова полный DAILY_ROTATION_LIMIT доступен
assert second.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT
# ── token never leaks ────────────────────────────────────────────────────────
async def test_token_never_appears_in_reason_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, _ = _fake_async_client(response=(200, {"ip": "1.1.1.1"}), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert SECRET_TOKEN not in (result.reason or "")
assert SECRET_TOKEN not in (result.new_ip or "")
async def test_token_never_appears_in_reason_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr("sentry_sdk.capture_message", lambda *a, **kw: None)
fake_client, _ = _fake_async_client(
response=(401, {"message": "Unauthenticated"}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert SECRET_TOKEN not in (result.reason or "")
assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations)
async def test_token_never_appears_in_reason_on_network_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""httpx-исключения могут нести полный request-контекст (URL/детали) —
прецедент утечки: app.api.v1.admin.rotate_proxy_ip (~line 2400). Здесь токен
живёт только в headers (не в URL), но проверяем end-to-end: даже если
exception-текст содержит секрет (симулируем это явно), наружу он не идёт."""
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
boom = httpx.ConnectError(f"connection failed while POSTing token={SECRET_TOKEN}")
fake_client, _ = _fake_async_client(response=None, exception=boom)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert SECRET_TOKEN not in (result.reason or "")
assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations)
# сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится
assert db.rotations[0]["http_status"] is None
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
# exception class name (не секрет) в note — оператор отличит "не дозвонились"
# (ConnectError) от "дозвонились, зависли" (ReadTimeout).
assert "ConnectError" in (db.rotations[0]["note"] or "")
async def test_token_never_appears_on_provider_error_status(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
fake_client, _ = _fake_async_client(
response=(500, {"message": "internal error"}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row())
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert SECRET_TOKEN not in (result.reason or "")
# провайдер прошёл auth и ответил своей ошибкой (500) — засчитывается в квоту
assert db.rotations[0]["http_status"] == 500
assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type]
async def test_token_never_appears_in_log_messages_or_sentry_text(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Расширенное leak-покрытие (security review PR #2611): предыдущие тесты
проверяли только reason/note. Здесь — текст, реально уходящий в logging и в
Sentry (не exc_info-traceback, который по дизайну МОЖЕТ нести детали
исключения — см. модуль docstring; это осознанно разрешённое место).
caplog.records[i].getMessage() возвращает форматированный msg %% args, БЕЗ
exc_text — то есть эта проверка ловит именно "секрет попал в аргумент
logger.*()", а не в traceback.
"""
sentry_texts: list[str] = []
monkeypatch.setattr(
"sentry_sdk.capture_message",
lambda msg, level=None: sentry_texts.append(msg),
)
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
scenarios: list[_LogScenario] = [
("success", _DEFAULT_ROTATE_URL, (200, {"ip": "1.1.1.1"}), None),
("401", _DEFAULT_ROTATE_URL, (401, {"message": "Unauthenticated"}), None),
("provider_500", _DEFAULT_ROTATE_URL, (500, {"message": "err"}), None),
(
"network_error",
_DEFAULT_ROTATE_URL,
None,
httpx.ConnectError(f"boom token={SECRET_TOKEN}"),
),
("foreign_host", "https://changeip.mobileproxy.space/?proxy_key=x", None, None),
]
for name, rotate_url, response, exception in scenarios:
if response is not None or exception is not None:
fake_client, _ = _fake_async_client(response=response, exception=exception)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
else:
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(_proxy_row(rotate_url=rotate_url))
with caplog.at_level(logging.DEBUG):
caplog.clear()
await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
for record in caplog.records:
assert (
SECRET_TOKEN not in record.getMessage()
), f"scenario={name}: token leaked into log message args"
assert sentry_texts, "expected at least one Sentry capture (401 scenario)"
assert all(SECRET_TOKEN not in text for text in sentry_texts)
# ── proxy not found ──────────────────────────────────────────────────────────
async def test_unknown_proxy_id_returns_neutral_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(None)
result = await proxy_rotation.rotate_proxy(db, 999) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None