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
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).
509 lines
23 KiB
Python
509 lines
23 KiB
Python
"""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
|