gendesign/tradein-mvp/backend/tests/test_cian_session.py
bot-backend cf3d4850e2
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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) Failing after 1m3s
fix(tradein/scrapers): три прод-пути остались на chrome120, пока kit ушёл на chrome146
#3034 свёл impersonate к единственной константе внутри scraper_kit и поднял
профиль до chrome146. Сторож литерала сканирует только пакет, а его докстрока
объявила остальное «отдельным периметром вне scope», сославшись на #2361 F4a.
Периметр не спящий — он ходит в сеть каждый день, а #2361 к тому моменту был
закрыт, то есть отсылка вела в никуда.

На chrome120 оставались:

  app/services/cian_session.py:164        верификация куки Циана
  app/services/yandex_address_backfill.py:153   бэкфилл адресов
  app/tasks/yandex_detail_backfill.py:303       detail-бэкфилл

Разрыв в 31 мажорную версию живёт в TLS-отпечатке (JA3/JA4), а не в строке
User-Agent, поэтому сменой прокси он не лечится.

ПРО ЦИАН ОТДЕЛЬНО. По #2673 оценка Циана мертва с 29 июня — «куки протухли,
ни одной новой строки 37 дней». Путь, которым проверяется живость этих куки,
всё это время представлялся площадке браузером двухлетней давности. Причину
этим не объявляю: утверждаю, что при таком отпечатке отличить «куки протухли»
от «нас узнали по рукопожатию» нечем.

ТЕСТ ЗАКРЕПЛЯЛ ДЕФЕКТ. test_cian_session прибивал chrome120 гвоздём: подъём
профиля в kit ронял бы этот тест, а «починкой» выглядел бы возврат к
устаревшему профилю. Теперь тест сверяется с DEFAULT_IMPERSONATE.

Сторож литерала расширен на backend/app — без этого периметр возвращается
молча, что уже один раз и произошло. Намеренно НЕ входят tests/fixtures/**
(номер профиля там — часть записи о том, чем снят фикстур-HTML) и scripts/**
(разовые инструменты, в прод-путях не участвуют).

Исторические замеры в комментариях сохранены как замеры: «curl_cffi с
kit-профилем (на момент замера — Chrome 120)» вместо переписывания истории.

Проверено: сканер сторожа на дереве даёт ноль нарушителей, на подсаженном
литерале краснеет; все изменённые модули компилируются.

Refs #3148
2026-08-27 18:18:05 +03:00

494 lines
20 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.

"""Tests for cian_session — cookie management service."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
from app.services.cian_session import (
CIAN_REQUIRED_COOKIES,
VERIFY_BAN_SENTINEL,
VERIFY_MARKUP_CHANGED_SENTINEL,
VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
_classify_verify_response,
load_session,
mark_session_invalid,
save_session,
verify_session,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def mock_db() -> MagicMock:
db = MagicMock()
# Default: no row found (load_session → None)
db.execute.return_value.mappings.return_value.first.return_value = None
return db
@pytest.fixture(autouse=True)
def _mock_resolve_proxy_url_sync(monkeypatch: pytest.MonkeyPatch) -> None:
"""#2825: verify_session больше не читает settings.cian_proxy_url напрямую, а
зовёт resolve_proxy_url_sync("cian") (пул scrape_proxies + fallback внутри
app.services.proxy_egress, отдельно покрыт tests/services/test_proxy_egress.py).
Без мока это реальный SessionLocal() -> живая (падающая в test-окружении) БД,
из-за чего verify_session уходил в generic except ДО session.get и все
verify_session-тесты ниже ловили не то, что проверяют. Мокаем на уровне модуля,
чтобы не трогать каждый тест по отдельности."""
monkeypatch.setattr(
"app.services.cian_session.resolve_proxy_url_sync",
lambda source: "http://test-proxy.local:8080",
)
# ---------------------------------------------------------------------------
# CIAN_REQUIRED_COOKIES
# ---------------------------------------------------------------------------
def test_required_cookies_set_not_empty() -> None:
assert isinstance(CIAN_REQUIRED_COOKIES, set)
assert len(CIAN_REQUIRED_COOKIES) >= 5
def test_required_cookies_contains_key_names() -> None:
assert "_cian_visitor_session_id" in CIAN_REQUIRED_COOKIES
assert "_ym_uid" in CIAN_REQUIRED_COOKIES
assert "_cian_uid" in CIAN_REQUIRED_COOKIES
assert "tlsr_id" in CIAN_REQUIRED_COOKIES
assert "cf_clearance" in CIAN_REQUIRED_COOKIES
def test_required_cookies_includes_real_auth() -> None:
"""Real Cian DevTools probe (2026-05-23) — these cookies must pass filter."""
real_cookies_from_browser = {
"DMIR_AUTH",
"_CIAN_GK",
"_yasc",
"_ym_uid",
"_ym_d",
"_ym_isad",
"_ym_visorc",
"uxfb_card_satisfaction",
}
missing = real_cookies_from_browser - CIAN_REQUIRED_COOKIES
assert not missing, f"Filter missing real cookies: {missing}"
# ---------------------------------------------------------------------------
# save_session
# ---------------------------------------------------------------------------
def test_save_session_calls_pgp_sym_encrypt(mock_db: MagicMock) -> None:
save_session(mock_db, account_user_id=12345, cookies={"_ym_uid": "abc"})
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
assert "pgp_sym_encrypt" in sql_text
assert "cian_session_cookies" in sql_text
def test_save_session_uses_cast_not_colon_colon(mock_db: MagicMock) -> None:
"""Verify psycopg v3 compatibility — no ::bigint syntax in SQL."""
save_session(mock_db, account_user_id=99, cookies={"csrftoken": "x"})
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
# CAST(:x AS type) is required; ::type would fail with psycopg v3 named params
assert "CAST(" in sql_text
assert "::bigint" not in sql_text
def test_save_session_commits(mock_db: MagicMock) -> None:
save_session(mock_db, account_user_id=12345, cookies={"_ym_uid": "abc"})
assert mock_db.commit.called
def test_save_session_on_conflict_do_update(mock_db: MagicMock) -> None:
save_session(mock_db, account_user_id=1, cookies={"_ym_uid": "v"})
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
assert "ON CONFLICT" in sql_text
assert "DO UPDATE" in sql_text
def test_save_session_passes_correct_params(mock_db: MagicMock) -> None:
save_session(mock_db, account_user_id=777, cookies={"_ga": "GA1.2.x"}, ttl_days=14)
_, kwargs = mock_db.execute.call_args
params = kwargs if kwargs else mock_db.execute.call_args[0][1]
# params могут передаваться как второй позиционный аргумент
call_args = mock_db.execute.call_args
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("params", {})
assert params["uid"] == 777
assert params["ttl_days"] == 14
cookies_payload = json.loads(params["cookies_json"])
assert cookies_payload == {"_ga": "GA1.2.x"}
# ---------------------------------------------------------------------------
# load_session
# ---------------------------------------------------------------------------
def test_load_session_returns_none_when_empty(mock_db: MagicMock) -> None:
result = load_session(mock_db)
assert result is None
def test_load_session_decodes_json(mock_db: MagicMock) -> None:
mock_row = {
"account_user_id": 12345,
"cookies_json": json.dumps({"_ym_uid": "abc", "_cian_uid": "def"}),
"expires_at_estimate": None,
}
mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
result = load_session(mock_db)
assert result == {"_ym_uid": "abc", "_cian_uid": "def"}
def test_load_session_updates_last_used_at(mock_db: MagicMock) -> None:
mock_row = {
"account_user_id": 42,
"cookies_json": json.dumps({"session-id": "s"}),
"expires_at_estimate": None,
}
mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
load_session(mock_db)
# Должно быть минимум 2 вызова execute: SELECT + UPDATE last_used_at
assert mock_db.execute.call_count >= 2
# ---------------------------------------------------------------------------
# mark_session_invalid
# ---------------------------------------------------------------------------
def test_mark_session_invalid_updates_table(mock_db: MagicMock) -> None:
mark_session_invalid(mock_db, account_user_id=12345)
args, _ = mock_db.execute.call_args
sql = str(args[0])
assert "last_invalid_at" in sql
assert "cian_session_cookies" in sql
def test_mark_session_invalid_commits(mock_db: MagicMock) -> None:
mark_session_invalid(mock_db, account_user_id=99)
assert mock_db.commit.called
def test_mark_session_invalid_uses_cast(mock_db: MagicMock) -> None:
mark_session_invalid(mock_db, account_user_id=5)
args, _ = mock_db.execute.call_args
sql = str(args[0])
assert "CAST(" in sql
assert "::bigint" not in sql
# ---------------------------------------------------------------------------
# _classify_verify_response (pure unit tests — no I/O)
# ---------------------------------------------------------------------------
def test_classify_403_returns_ban_sentinel() -> None:
"""HTTP 403 TLS/bot ban → VERIFY_BAN_SENTINEL (not None, not 'expired')."""
result = _classify_verify_response(403, None)
assert result is VERIFY_BAN_SENTINEL
assert result is not None # must NOT collapse into expired-cookies signal
def test_classify_401_returns_none() -> None:
"""HTTP 401 Unauthorized → None (genuinely expired cookies)."""
result = _classify_verify_response(401, None)
assert result is None
def test_classify_200_not_authenticated_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
"""200 with isAuthenticated=false → None (expired)."""
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: {"user": {"isAuthenticated": False, "userId": None}},
)
result = _classify_verify_response(200, "<html></html>")
assert result is None
def test_classify_200_authenticated_returns_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""200 with isAuthenticated=true → state dict."""
expected = {"user": {"isAuthenticated": True, "userId": 99}}
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: expected,
)
result = _classify_verify_response(200, "<html>x</html>")
assert result == expected
def test_classify_200_state_missing_returns_markup_changed_sentinel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""audit-scrapers finding 4: HTTP 200 но extract_state не нашёл auth-state
(Cian сменил вёрстку/MFE-схему header-frontend) → VERIFY_MARKUP_CHANGED_SENTINEL,
НЕ None. Раньше это конфлировалось с "cookies expired" (реальный логаут)."""
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: None,
)
result = _classify_verify_response(200, "<html></html>")
assert result is VERIFY_MARKUP_CHANGED_SENTINEL
assert result is not None # НЕ должно триггерить cookie-refresh alert
def test_classify_5xx_returns_source_unavailable_sentinel() -> None:
"""audit-scrapers finding 4: HTTP 500 (источник недоступен) →
VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None (cookies тут ни при чём)."""
result = _classify_verify_response(500, None)
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
assert result is not None
def test_classify_502_returns_source_unavailable_sentinel() -> None:
"""Любой non-200/403/401 статус (напр. 502 bad gateway) — источник недоступен."""
result = _classify_verify_response(502, None)
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
def test_classify_status_200_html_none_returns_source_unavailable_sentinel() -> None:
"""Defensive: status=200 но html=None (не должно случаться в проде, но
classifier не должен молча вернуть None='expired') → source-unavailable."""
result = _classify_verify_response(200, None)
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
def test_classify_403_is_distinct_from_401() -> None:
"""Ban and expired must not collapse to the same return value."""
ban = _classify_verify_response(403, None)
expired = _classify_verify_response(401, None)
assert ban is not expired
assert ban is not None
assert expired is None
def test_classify_all_four_outcomes_are_mutually_distinct() -> None:
"""audit-scrapers finding 4: expired (401) / ban (403) / source-unavailable (5xx)
/ markup-changed (200+extract_state=None) — четыре РАЗНЫХ сигнала, ни один не
коллапсирует в другой. Только expired (None) должен триггерить re-login alert."""
expired = _classify_verify_response(401, None)
ban = _classify_verify_response(403, None)
source_down = _classify_verify_response(500, None)
with pytest.MonkeyPatch.context() as mp:
mp.setattr("app.services.cian_session.extract_state", lambda html, mfe, key: None)
markup_changed = _classify_verify_response(200, "<html></html>")
outcomes = [expired, ban, source_down, markup_changed]
# None встречается ровно один раз (только expired) — остальные три truthy sentinel'а
# и все различны между собой (identity, не equality — это разные dict-объекты).
assert outcomes.count(None) == 1
assert expired is None
non_none = [o for o in outcomes if o is not None]
assert len(non_none) == 3
assert len({id(o) for o in non_none}) == 3
# ---------------------------------------------------------------------------
# verify_session (async) — integration with curl_cffi mock
# ---------------------------------------------------------------------------
def _make_cffi_resp(status_code: int, text: str = "") -> MagicMock:
resp = MagicMock()
resp.status_code = status_code
resp.text = text
return resp
@pytest.mark.asyncio
async def test_verify_session_403_ban_not_treated_as_expired(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""HTTP 403 from curl_cffi → VERIFY_BAN_SENTINEL, never None."""
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(return_value=_make_cffi_resp(403))
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"})
assert result is VERIFY_BAN_SENTINEL
assert result is not None # must NOT trigger cookie-refresh alert
@pytest.mark.asyncio
async def test_verify_session_401_is_expired(monkeypatch: pytest.MonkeyPatch) -> None:
"""HTTP 401 → None (genuine expiry)."""
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(return_value=_make_cffi_resp(401))
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"})
assert result is None
@pytest.mark.asyncio
async def test_verify_session_authenticated(monkeypatch: pytest.MonkeyPatch) -> None:
"""200 + isAuthenticated=true → returns state dict."""
expected_state = {"user": {"isAuthenticated": True, "userId": 102963817}}
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: expected_state,
)
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(return_value=_make_cffi_resp(200, "<html>x</html>"))
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x", "_CIAN_GK": "y"})
assert result is not None
assert result["user"]["isAuthenticated"] is True
assert result["user"]["userId"] == 102963817
@pytest.mark.asyncio
async def test_verify_session_not_authenticated_returns_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""200 + isAuthenticated=false → None (expired)."""
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: {"user": {"isAuthenticated": False, "userId": None}},
)
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(return_value=_make_cffi_resp(200, "<html></html>"))
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"})
assert result is None
@pytest.mark.asyncio
async def test_verify_session_state_missing_returns_markup_changed_sentinel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""audit-scrapers finding 4: 200 + extract_state returns None (markup changed)
→ VERIFY_MARKUP_CHANGED_SENTINEL, НЕ None. Раньше ложно триггерило "cookies
expired, please re-upload" для реальной причины "Cian сменил вёрстку"."""
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: None,
)
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(return_value=_make_cffi_resp(200, "<html></html>"))
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"})
assert result is VERIFY_MARKUP_CHANGED_SENTINEL
assert result is not None
@pytest.mark.asyncio
async def test_verify_session_5xx_returns_source_unavailable_sentinel() -> None:
"""audit-scrapers finding 4: HTTP 500 → VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
НЕ None. Источник временно недоступен — cookies тут ни при чём, вызывающий
не должен помечать сессию invalid / просить re-upload."""
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(return_value=_make_cffi_resp(500))
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"})
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
assert result is not None
@pytest.mark.asyncio
async def test_verify_session_network_error_returns_source_unavailable_sentinel() -> None:
"""audit-scrapers finding 4: сетевой/транспортный сбой (timeout, connection
reset и т.п.) → VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None. Раньше generic
except возвращал None — конфлировал сетевой сбой с протухшими cookies."""
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(side_effect=ConnectionError("connection reset by peer"))
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"})
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
assert result is not None
@pytest.mark.asyncio
async def test_verify_session_pool_exhausted_returns_source_unavailable_with_error_log(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""#2825 fail-closed (#2616): пул scrape_proxies исчерпан для cian (все узлы
забанены/нездоровы) — session.get НЕ вызывается (никуда не ходим без egress),
возвращается VERIFY_SOURCE_UNAVAILABLE_SENTINEL, но с ERROR-логом (не warning,
отдельным от обычного network-error пути) — явная деградация, а не проглатывание."""
from app.services.proxy_egress import ProxyPoolExhaustedError
def _raise(source: str) -> str | None:
raise ProxyPoolExhaustedError(
source, pool_total=2, banned_for_source=2, unhealthy_or_disabled=0
)
monkeypatch.setattr("app.services.cian_session.resolve_proxy_url_sync", _raise)
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(return_value=_make_cffi_resp(200))
with (
patch("app.services.cian_session.AsyncSession", return_value=mock_session),
caplog.at_level("WARNING"),
):
result = await verify_session({"DMIR_AUTH": "x"})
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
mock_session.get.assert_not_called()
errors = [rec for rec in caplog.records if rec.levelname == "ERROR"]
assert any("пул прокси исчерпан" in rec.message.lower() for rec in errors)
@pytest.mark.asyncio
async def test_verify_session_uses_kit_impersonate_profile() -> None:
"""AsyncSession строится с kit-профилем, а не с литералом.
Тест раньше прибивал гвоздём `chrome120` — то есть закреплял ровно тот дефект,
ради которого #3034 заводился: обновление TLS-профиля в kit ломало бы этот тест,
и «починка» состояла бы в возврате к устаревшему профилю.
"""
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = AsyncMock(return_value=_make_cffi_resp(403))
with patch("app.services.cian_session.AsyncSession", return_value=mock_session) as mock_cls:
await verify_session({"DMIR_AUTH": "x"})
_, kwargs = mock_cls.call_args
assert kwargs.get("impersonate") == DEFAULT_IMPERSONATE