Merge pull request 'feat(tradein/proxy): ротация exit-IP ASocks по бану со счётчиком и громким отказом (#2600)' (#2611) from feat/tradein-asocks-rotation into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m2s
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m2s
This commit is contained in:
commit
887df54908
6 changed files with 1040 additions and 0 deletions
|
|
@ -70,6 +70,7 @@ from app.core.db import SessionLocal, get_db
|
|||
from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
|
||||
from app.services import cian_session as cian_session_svc
|
||||
from app.services import domclick_session as domclick_session_svc
|
||||
from app.services import proxy_rotation as proxy_rotation_svc
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.geocoder import geocode
|
||||
from app.services.scheduler import has_running_run
|
||||
|
|
@ -2889,3 +2890,44 @@ def patch_proxy(
|
|||
created_at=_iso(row["created_at"]),
|
||||
updated_at=_iso(row["updated_at"]),
|
||||
)
|
||||
|
||||
|
||||
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
|
||||
#
|
||||
# ОТДЕЛЬНО от /scraper/{source}/rotate-ip (выше) — тот работает по env-прокси
|
||||
# mobileproxy для avito/cian/yandex (changeip-ссылка, ротация "на лету" без
|
||||
# лимитов), не трогается. Этот эндпоинт — по proxy_id из пула scrape_proxies
|
||||
# (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
|
||||
# app.services.proxy_rotation.rotate_proxy.
|
||||
|
||||
|
||||
class ProxyRotateResponse(BaseModel):
|
||||
ok: bool
|
||||
reason: str | None = None
|
||||
new_ip: str | None = None
|
||||
rotations_remaining_today: int
|
||||
|
||||
|
||||
@router.post("/proxies/{proxy_id}/rotate", response_model=ProxyRotateResponse)
|
||||
async def rotate_pool_proxy(
|
||||
proxy_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> ProxyRotateResponse:
|
||||
"""Ручная ротация exit-IP одного прокси пула (#2600 п.5).
|
||||
|
||||
Делегирует в app.services.proxy_rotation.rotate_proxy — читает rotate_url
|
||||
прокси из scrape_proxies, требует ASOCKS_API_TOKEN (settings.asocks_api_token),
|
||||
проверяет суточный лимит (3/сутки, scrape_proxy_rotations) ДО обращения к API.
|
||||
ok=False — ожидаемая бизнес-ситуация (нет rotate_url / нет токена / лимит /
|
||||
провайдер отказал), НЕ HTTPException; reason ВСЕГДА нейтральный, без токена.
|
||||
|
||||
ПОКА без автотриггера по бану (issue #2600 п.2: сигнал бана до пула не
|
||||
доходит — страница-заглушка отдаёт 200) — только этот ручной вызов.
|
||||
"""
|
||||
result = await proxy_rotation_svc.rotate_proxy(db, proxy_id)
|
||||
return ProxyRotateResponse(
|
||||
ok=result.ok,
|
||||
reason=result.reason,
|
||||
new_ip=result.new_ip,
|
||||
rotations_remaining_today=result.rotations_remaining_today,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -548,6 +548,18 @@ class Settings(BaseSettings):
|
|||
proxy_rotate_attempt_timeout_s: float = 8.0
|
||||
proxy_rotate_attempts: int = 3
|
||||
|
||||
# ── ASocks pool-proxy rotation (#2600) ───────────────────────────────────
|
||||
# Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip
|
||||
# (app.services.proxy_rotation). Документированный публичный API (GET
|
||||
# /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает —
|
||||
# подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка
|
||||
# веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное
|
||||
# решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy
|
||||
# логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех
|
||||
# прокси недоступна (rotate_proxy возвращает внятный отказ, не падает).
|
||||
# ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе.
|
||||
asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN")
|
||||
|
||||
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
|
||||
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
|
||||
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
|
||||
|
|
|
|||
370
tradein-mvp/backend/app/services/proxy_rotation.py
Normal file
370
tradein-mvp/backend/app/services/proxy_rotation.py
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5).
|
||||
|
||||
АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health — параллельный
|
||||
PR #2609, конфликт исключён: вся новая логика тут, в новом модуле).
|
||||
|
||||
Контекст (эмпирика, issue #2600 п.5 — проверено владельцем аккаунта/пробой):
|
||||
- Документированный публичный API ASocks (GET /v2/proxy/refresh/{portId}?apiKey=)
|
||||
для безлимитных портов НЕ работает.
|
||||
- Ротация сменой session-суффикса логина (-session-N) НЕ работает — exit-IP
|
||||
не меняется (три варианта дали один и тот же IP).
|
||||
- Единственный рабочий путь — ручка веб-кабинета:
|
||||
POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip
|
||||
Authorization: Bearer <токен>
|
||||
Без заголовка провайдер отдаёт 401 {"success": false, "message": "Unauthenticated"}.
|
||||
scrape_proxies.rotate_url уже несёт этот URL (миграция 199) — токен НЕ в URL,
|
||||
он только в ASOCKS_API_TOKEN (env, app.core.config.settings.asocks_api_token).
|
||||
- Лимит провайдера: 3 ротации в сутки на порт.
|
||||
- Токен — сессионный, однажды протухнет (осознанное решение владельца аккаунта).
|
||||
Когда это случится, провайдер ответит 401 — это ГРОМКИЙ отказ ниже
|
||||
(logger.error + Sentry/GlitchTip capture_message), а не молчаливая остановка.
|
||||
|
||||
Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198):
|
||||
Против лимита 3/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера
|
||||
и обработанные им — т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование:
|
||||
401 — это буквально описание провайдера "Unauthenticated": запрос отсеян на
|
||||
уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не
|
||||
мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые
|
||||
ошибки (таймаут / разрыв соединения — ответа вообще нет) по той же логике не
|
||||
считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные
|
||||
отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не
|
||||
доходят вовсе — в таблицу не пишутся и лимит не трогают.
|
||||
|
||||
quota-consuming := http_status IS NOT NULL AND http_status != 401
|
||||
(успех 200 И любой не-401 ответ провайдера, включая его собственные 4xx/5xx —
|
||||
если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до
|
||||
реальной rotate-логики и мог быть учтён в лимите на его стороне).
|
||||
|
||||
⛔ Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте
|
||||
исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через
|
||||
str(exc) — см. комментарий в app.api.v1.admin.rotate_proxy_ip (~line 2400):
|
||||
httpx-исключения несут полный request URL/детали, поэтому наружу — только
|
||||
нейтральный reason, полные детали — в лог с exc_info=True.
|
||||
|
||||
⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка
|
||||
НЕОДНОРОДНА — часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip-
|
||||
ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`,
|
||||
см. app.api.v1.admin._provider_rotate_url / avito_proxy_rotate_url), не ASocks.
|
||||
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы
|
||||
на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно,
|
||||
реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как
|
||||
успех ASocks. rotate_proxy ПЕРЕД любым HTTP-вызовом проверяет
|
||||
urlparse(rotate_url).hostname == ALLOWED_ROTATE_HOST (https-only) — несовпадение
|
||||
это ОТКАЗ (ok=False, нейтральный reason), а НЕ попытка безголового запроса без
|
||||
Authorization: смысл ручной ротации — конкретный провайдер (ASocks), молчаливый
|
||||
вызов чужой ручки без авторизации — это сюрприз оператору (он думает "ASocks
|
||||
ротировал", а фактически задел mobileproxy), которого проще не допустить, чем
|
||||
потом объяснять админу расхождение счётчиков.
|
||||
|
||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_ROTATE_HOST",
|
||||
"DAILY_ROTATION_LIMIT",
|
||||
"RotationResult",
|
||||
"rotate_proxy",
|
||||
]
|
||||
|
||||
# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика).
|
||||
DAILY_ROTATION_LIMIT = 3
|
||||
|
||||
# Таймаут POST refresh-ip. Пункт задачи требует "~30с".
|
||||
_ROTATE_TIMEOUT_S = 30.0
|
||||
|
||||
# Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке
|
||||
# (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может
|
||||
# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова.
|
||||
ALLOWED_ROTATE_HOST = "api.asocks.com"
|
||||
|
||||
|
||||
def _is_allowed_rotate_url(url: str) -> bool:
|
||||
"""https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо —
|
||||
urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return False
|
||||
return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST
|
||||
|
||||
|
||||
@dataclass
|
||||
class RotationResult:
|
||||
"""Результат попытки ротации exit-IP одного прокси. reason — ВСЕГДА нейтральный
|
||||
(безопасен для HTTP-ответа клиенту), никогда не несёт токен/секреты."""
|
||||
|
||||
ok: bool
|
||||
reason: str | None
|
||||
new_ip: str | None = None
|
||||
# Сколько quota-consuming попыток остаётся сегодня ПОСЛЕ этой попытки (см. модуль
|
||||
# docstring за определением quota-consuming). Для локально отклонённых попыток
|
||||
# (no rotate_url/no token) не относится к текущему прокси — просто текущий остаток.
|
||||
rotations_remaining_today: int = DAILY_ROTATION_LIMIT
|
||||
|
||||
|
||||
def _quota_used_today(db: Session, proxy_id: int) -> int:
|
||||
"""Число quota-consuming попыток за последние 24ч (см. docstring модуля).
|
||||
|
||||
http_status IS NOT NULL AND != 401 — успех И любой не-401 ответ провайдера.
|
||||
401 (auth-отсев) и сетевые ошибки (http_status IS NULL) не считаются.
|
||||
"""
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(*) AS n
|
||||
FROM scrape_proxy_rotations
|
||||
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||
AND rotated_at > now() - interval '24 hours'
|
||||
AND http_status IS NOT NULL
|
||||
AND http_status != 401
|
||||
"""
|
||||
),
|
||||
{"proxy_id": proxy_id},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
return int(row["n"]) if row is not None else 0
|
||||
|
||||
|
||||
def _record_attempt(
|
||||
db: Session,
|
||||
proxy_id: int,
|
||||
*,
|
||||
success: bool,
|
||||
http_status: int | None,
|
||||
note: str | None,
|
||||
) -> None:
|
||||
"""Записать попытку ротации в аудит-таблицу. Вызывается ТОЛЬКО когда HTTP-запрос
|
||||
к провайдеру реально был сделан (локально отклонённые попытки не пишутся —
|
||||
см. модуль docstring)."""
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO scrape_proxy_rotations (proxy_id, success, http_status, note)
|
||||
VALUES (
|
||||
CAST(:proxy_id AS bigint),
|
||||
CAST(:success AS boolean),
|
||||
CAST(:http_status AS integer),
|
||||
CAST(:note AS text)
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"proxy_id": proxy_id, "success": success, "http_status": http_status, "note": note},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _alert_stale_token(proxy_id: int) -> None:
|
||||
"""Громкий отказ на 401: logger.error + событие в Sentry/GlitchTip (best-effort).
|
||||
|
||||
401 значит, что провайдер отверг Authorization-заголовок — токен протух (issue
|
||||
#2600 п.5: "Токен — сессионный, однажды протухнет. Это осознанное решение
|
||||
владельца"). Молчаливая остановка ротации недопустима — операторы должны узнать
|
||||
об этом сразу, а не когда прокси уже забанены неделю.
|
||||
"""
|
||||
logger.error(
|
||||
"proxy_rotation: ASocks REJECTED Authorization (401) for proxy_id=%d — "
|
||||
"ASOCKS_API_TOKEN likely EXPIRED, IP rotation is now BLOCKED for this proxy "
|
||||
"until the token is refreshed in web-cabinet + env",
|
||||
proxy_id,
|
||||
)
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
sentry_sdk.capture_message(
|
||||
f"ASocks rotation token rejected (401) for proxy_id={proxy_id} — "
|
||||
"ASOCKS_API_TOKEN expired, IP rotation blocked until refreshed",
|
||||
level="error",
|
||||
)
|
||||
except Exception:
|
||||
pass # sentry_sdk not initialised in dev — best-effort only
|
||||
|
||||
|
||||
def _extract_new_ip(resp: httpx.Response) -> str | None:
|
||||
"""Best-effort вытащить новый exit-IP из ответа провайдера. Формат ответа
|
||||
refresh-ip для безлимитных портов ASocks не документирован (issue #2600 п.5) —
|
||||
парсинг заведомо defensive, неудача не является ошибкой ротации."""
|
||||
try:
|
||||
data: Any = resp.json()
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
for key in ("new_ip", "ip", "exit_ip"):
|
||||
val = data.get(key)
|
||||
if val:
|
||||
return str(val)
|
||||
nested = data.get("data")
|
||||
if isinstance(nested, dict):
|
||||
for key in ("new_ip", "ip", "exit_ip"):
|
||||
val = nested.get(key)
|
||||
if val:
|
||||
return str(val)
|
||||
return None
|
||||
|
||||
|
||||
async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
|
||||
"""Сменить exit-IP одного прокси пула через ASocks refresh-ip (#2600 п.5).
|
||||
|
||||
Порядок:
|
||||
1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный.
|
||||
2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка).
|
||||
3. rotate_url хост != ALLOWED_ROTATE_HOST (https://api.asocks.com) → ok=False
|
||||
ДО HTTP-вызова — токен не должен уйти на чужой провайдер (mobileproxy
|
||||
changeip и т.п. в этой же колонке пула, см. "⛔ Хост-пиннинг" в модуле).
|
||||
4. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False,
|
||||
внятный отказ, ничего не ломается.
|
||||
5. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ
|
||||
обращения к API.
|
||||
6. POST rotate_url с Authorization: Bearer <token>, timeout ~30с.
|
||||
- Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL
|
||||
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
|
||||
- 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается
|
||||
в лимите), нейтральный reason.
|
||||
- Другой 4xx/5xx → аудит-запись (считается в лимите — провайдер прошёл
|
||||
auth и ответил своей бизнес-логикой), нейтральный reason.
|
||||
- 2xx → аудит-запись success=True (считается в лимите), new_ip best-effort.
|
||||
|
||||
Ни в одном из reason/логов НЕ появляется токен.
|
||||
"""
|
||||
row = (
|
||||
db.execute(
|
||||
text("SELECT id, rotate_url FROM scrape_proxies WHERE id = CAST(:id AS bigint)"),
|
||||
{"id": proxy_id},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
if row is None:
|
||||
return RotationResult(ok=False, reason="proxy not found")
|
||||
|
||||
rotate_url = row["rotate_url"]
|
||||
if not rotate_url:
|
||||
logger.info(
|
||||
"proxy_rotation: proxy_id=%d has no rotate_url — rotation not supported", proxy_id
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False, reason="rotation not supported for this proxy (no rotate_url configured)"
|
||||
)
|
||||
|
||||
if not _is_allowed_rotate_url(rotate_url):
|
||||
# scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут
|
||||
# mobileproxy changeip-ссылки с ИХ секретом) — отправлять наш
|
||||
# Authorization: Bearer <ASOCKS_API_TOKEN> на непроверенный хост нельзя.
|
||||
# Логируем ТОЛЬКО hostname (не полный url — на других провайдерах он
|
||||
# несёт их собственный секрет в query-string, тот же класс утечки, что
|
||||
# и в rotate_proxy_ip, см. модуль docstring).
|
||||
logger.warning(
|
||||
"proxy_rotation: proxy_id=%d rotate_url host=%r is not the allowed ASocks host "
|
||||
"(%s) — refusing before any HTTP call to avoid leaking the token to it",
|
||||
proxy_id,
|
||||
urlparse(rotate_url).hostname,
|
||||
ALLOWED_ROTATE_HOST,
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False, reason="rotation not supported for this proxy (unexpected rotate host)"
|
||||
)
|
||||
|
||||
token = settings.asocks_api_token
|
||||
if not token:
|
||||
logger.warning(
|
||||
"proxy_rotation: ASOCKS_API_TOKEN not configured — proxy_id=%d rotation skipped",
|
||||
proxy_id,
|
||||
)
|
||||
return RotationResult(ok=False, reason="rotation not configured (missing API token)")
|
||||
|
||||
used = _quota_used_today(db, proxy_id)
|
||||
if used >= DAILY_ROTATION_LIMIT:
|
||||
logger.warning(
|
||||
"proxy_rotation: daily limit reached proxy_id=%d used=%d/%d — skipping API call",
|
||||
proxy_id,
|
||||
used,
|
||||
DAILY_ROTATION_LIMIT,
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False,
|
||||
reason=f"daily rotation limit reached ({DAILY_ROTATION_LIMIT}/day)",
|
||||
rotations_remaining_today=0,
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client:
|
||||
resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"})
|
||||
except Exception as exc:
|
||||
# Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера,
|
||||
# значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести
|
||||
# служебные детали соединения) — только exc_info=True в лог. type(exc).__name__
|
||||
# секрета не несёт (это имя класса — ConnectError/ReadTimeout/…) и в note
|
||||
# ПОЛЕЗЕН оператору: отличить "не дозвонились" от "дозвонились, зависли".
|
||||
logger.warning(
|
||||
"proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True
|
||||
)
|
||||
_record_attempt(
|
||||
db,
|
||||
proxy_id,
|
||||
success=False,
|
||||
http_status=None,
|
||||
note=f"request failed: {type(exc).__name__}",
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False,
|
||||
reason="rotation request failed (network error)",
|
||||
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used),
|
||||
)
|
||||
|
||||
status = resp.status_code
|
||||
|
||||
if status == 401:
|
||||
_alert_stale_token(proxy_id)
|
||||
_record_attempt(
|
||||
db,
|
||||
proxy_id,
|
||||
success=False,
|
||||
http_status=401,
|
||||
note="unauthenticated — token expired/invalid (excluded from daily quota)",
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False,
|
||||
reason="rotation service rejected credentials — alerted, contact operator",
|
||||
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used),
|
||||
)
|
||||
|
||||
if status >= 400:
|
||||
logger.warning(
|
||||
"proxy_rotation: provider returned error proxy_id=%d status=%d", proxy_id, status
|
||||
)
|
||||
_record_attempt(
|
||||
db, proxy_id, success=False, http_status=status, note="provider returned error"
|
||||
)
|
||||
return RotationResult(
|
||||
ok=False,
|
||||
reason=f"rotation request failed (provider status {status})",
|
||||
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
|
||||
)
|
||||
|
||||
new_ip = _extract_new_ip(resp)
|
||||
logger.info("proxy_rotation: rotated proxy_id=%d status=%d new_ip=%s", proxy_id, status, new_ip)
|
||||
_record_attempt(db, proxy_id, success=True, http_status=status, note=None)
|
||||
return RotationResult(
|
||||
ok=True,
|
||||
reason=None,
|
||||
new_ip=new_ip,
|
||||
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
|
||||
)
|
||||
49
tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql
Normal file
49
tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
-- 198_scrape_proxy_rotations.sql
|
||||
-- Issue #2600 п.5 — ротация exit-IP прокси ASocks по бану, со счётчиком и громким
|
||||
-- отказом. АДДИТИВНО, не трогает scrape_proxies (157_scrape_proxies.sql) кроме
|
||||
-- FK-ссылки; не трогает proxy_pool.py (параллельный PR #2609).
|
||||
--
|
||||
-- WHY:
|
||||
-- Провайдер (ASocks, безлимитные порты) ограничивает ручную ротацию exit-IP тремя
|
||||
-- вызовами в сутки на порт (эмпирика, владелец аккаунта). app.services.proxy_rotation
|
||||
-- должен и проверять этот лимит ПЕРЕД обращением к API, и вести аудит попыток —
|
||||
-- без отдельной таблицы истории лимит негде считать (scrape_proxies хранит только
|
||||
-- текущее состояние, не историю).
|
||||
--
|
||||
-- Semantics:
|
||||
-- Одна строка = одна попытка ротации (успешная ИЛИ неуспешная), но НЕ каждый
|
||||
-- вызов rotate_proxy() пишет строку — локально отклонённые попытки (нет
|
||||
-- rotate_url / нет ASOCKS_API_TOKEN / лимит уже исчерпан) вообще не доходят до
|
||||
-- HTTP-вызова и в таблицу не пишутся (см. app.services.proxy_rotation docstring
|
||||
-- за полным обоснованием "какие попытки считать против лимита").
|
||||
-- http_status NULL = сетевая ошибка (ответа от провайдера не было вообще).
|
||||
--
|
||||
-- Idempotency:
|
||||
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS → повторный прогон
|
||||
-- no-op (auto-apply strict на деплое это требует). Весь файл в BEGIN/COMMIT.
|
||||
--
|
||||
-- Dependencies:
|
||||
-- 157_scrape_proxies.sql (scrape_proxies.id — FK-таргет).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scrape_proxy_rotations (
|
||||
id bigserial PRIMARY KEY,
|
||||
proxy_id bigint NOT NULL REFERENCES scrape_proxies (id),
|
||||
rotated_at timestamptz NOT NULL DEFAULT now(),
|
||||
success boolean NOT NULL,
|
||||
http_status integer,
|
||||
note text
|
||||
);
|
||||
|
||||
COMMENT ON TABLE scrape_proxy_rotations IS
|
||||
'Аудит + суточный лимит (#2600 п.5) ручных ротаций exit-IP через ASocks '
|
||||
'refresh-ip. Лимит провайдера — 3 попытки/сутки на порт; app.services.'
|
||||
'proxy_rotation._quota_used_today считает только строки с http_status '
|
||||
'IS NOT NULL AND != 401 (реально дошедшие до провайдера) за последние 24ч.';
|
||||
|
||||
-- Проверка суточного лимита + выборка истории по прокси: (proxy_id, rotated_at).
|
||||
CREATE INDEX IF NOT EXISTS idx_scrape_proxy_rotations_proxy_time
|
||||
ON scrape_proxy_rotations (proxy_id, rotated_at);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
-- 199_scrape_proxies_asocks_rotate_url.sql
|
||||
-- Issue #2600 п.5 — проставить rotate_url для четырёх ASocks unlimited-портов пула,
|
||||
-- чтобы app.services.proxy_rotation.rotate_proxy имел куда стучаться.
|
||||
--
|
||||
-- WHY:
|
||||
-- scrape_proxies.rotate_url для этих 4 строк сейчас NULL (загружены через
|
||||
-- POST /proxies/bulk без rotate_url). Единственный рабочий способ ротации exit-IP
|
||||
-- для ASocks-безлимитных портов — ручка веб-кабинета
|
||||
-- POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip с заголовком
|
||||
-- Authorization: Bearer <ASOCKS_API_TOKEN> (env, НЕ в URL — секретов в миграции
|
||||
-- нет). Документированный публичный GET /v2/proxy/refresh/{portId}?apiKey= для
|
||||
-- безлимитных портов не работает (подтверждено владельцем аккаунта); ротация
|
||||
-- session-суффиксом логина тоже не работает (проверено пробой, три варианта —
|
||||
-- один и тот же exit-IP).
|
||||
--
|
||||
-- Matching (важно — НЕ по id):
|
||||
-- scrape_proxies.id может разъехаться между средами (dev/stage/prod грузятся
|
||||
-- bulk-ручкой независимо) — сопоставляем по адресу host:port, зашитому в конец
|
||||
-- url (scrape_proxies.url — всегда 'scheme://[user:pass@]host:port' БЕЗ пути,
|
||||
-- см. admin.py _mask_proxy_url/urlparse-логику и 157_scrape_proxies.sql) через
|
||||
-- right(url, length(hostport)) = hostport. portId → host:port (проверено
|
||||
-- владельцем аккаунта, issue #2600 п.5):
|
||||
-- 223610715 → 212.8.249.134:10423
|
||||
-- 225031312 → 190.2.145.131:10313
|
||||
-- 231878029 → 175.110.115.153:10492
|
||||
-- 231878030 → 109.236.82.42:11048
|
||||
--
|
||||
-- Idempotency:
|
||||
-- Обычный UPDATE ... WHERE — повторный прогон пишет то же значение, no-op по
|
||||
-- результату. Прокси, которых нет в пуле текущей среды (host:port не найден) —
|
||||
-- 0 строк обновлено, не ошибка. Весь файл в BEGIN/COMMIT.
|
||||
--
|
||||
-- Dependencies:
|
||||
-- 157_scrape_proxies.sql (scrape_proxies.rotate_url).
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE scrape_proxies
|
||||
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/223610715/refresh-ip',
|
||||
updated_at = now()
|
||||
WHERE right(url, length(CAST('212.8.249.134:10423' AS text))) = '212.8.249.134:10423';
|
||||
|
||||
UPDATE scrape_proxies
|
||||
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/225031312/refresh-ip',
|
||||
updated_at = now()
|
||||
WHERE right(url, length(CAST('190.2.145.131:10313' AS text))) = '190.2.145.131:10313';
|
||||
|
||||
UPDATE scrape_proxies
|
||||
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878029/refresh-ip',
|
||||
updated_at = now()
|
||||
WHERE right(url, length(CAST('175.110.115.153:10492' AS text))) = '175.110.115.153:10492';
|
||||
|
||||
UPDATE scrape_proxies
|
||||
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878030/refresh-ip',
|
||||
updated_at = now()
|
||||
WHERE right(url, length(CAST('109.236.82.42:11048' AS text))) = '109.236.82.42:11048';
|
||||
|
||||
COMMIT;
|
||||
509
tradein-mvp/backend/tests/services/test_proxy_rotation.py
Normal file
509
tradein-mvp/backend/tests/services/test_proxy_rotation.py
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
"""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
|
||||
Loading…
Add table
Reference in a new issue