gendesign/tradein-mvp/backend/tests/test_cian_session.py
lekss361 99dd403c45
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 39s
Deploy Trade-In / deploy (push) Successful in 31s
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / build-frontend (push) Has been skipped
fix(tradein): CIAN_REQUIRED_COOKIES — add real auth cookies (DMIR_AUTH + Cian session) (#480)
Updated CIAN_REQUIRED_COOKIES filter в cian_session.py по реальному DevTools-дампу logged-in cian.ru сессии (2026-05-23). Добавлены critical auth cookies: DMIR_AUTH (httpOnly), _CIAN_GK, _yasc, _ym_visorc, uxfb_card_satisfaction. Legacy entries сохранены как backward-compat fallback. Regression test test_required_cookies_includes_real_auth гарантирует что все реальные cookies проходят filter.

Fixes Stage 4 PR #457 (filter guessed без real probe — DMIR_AUTH был dropped → verify_session falsey).
2026-05-23 14:52:24 +00:00

254 lines
9 KiB
Python

"""Tests for cian_session — cookie management service."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.cian_session import (
CIAN_REQUIRED_COOKIES,
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
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# verify_session (async)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_verify_session_returns_none_when_state_missing(monkeypatch: pytest.MonkeyPatch) -> None:
"""extract_state returns None → verify_session returns None."""
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: None,
)
async def fake_get(url: str, **kwargs): # noqa: ARG001
class FakeResp:
text = "<html></html>"
def raise_for_status(self) -> None:
pass
return FakeResp()
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(return_value=_make_fake_resp("<html></html>"))
mock_client_cls.return_value = mock_client
result = await verify_session({"_ym_uid": "x"})
assert result is None
@pytest.mark.asyncio
async def test_verify_session_returns_none_when_not_authenticated(monkeypatch: pytest.MonkeyPatch) -> None:
"""state.user.isAuthenticated is false → verify_session returns None."""
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: {"user": {"isAuthenticated": False, "userId": None}},
)
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(return_value=_make_fake_resp("<html></html>"))
mock_client_cls.return_value = mock_client
result = await verify_session({"_ym_uid": "x"})
assert result is None
@pytest.mark.asyncio
async def test_verify_session_returns_state_when_authenticated(monkeypatch: pytest.MonkeyPatch) -> None:
"""state.user.isAuthenticated is 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,
)
with patch("httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(return_value=_make_fake_resp("<html>state here</html>"))
mock_client_cls.return_value = mock_client
result = await verify_session({"_ym_uid": "x", "_cian_uid": "y"})
assert result is not None
assert result["user"]["isAuthenticated"] is True
assert result["user"]["userId"] == 102963817
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_fake_resp(text: str) -> MagicMock:
resp = MagicMock()
resp.text = text
resp.raise_for_status = MagicMock()
return resp