All checks were successful
Deploy Trade-In / test (push) Successful in 24s
Deploy Trade-In / build-backend (push) Successful in 38s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 34s
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
352 lines
13 KiB
Python
352 lines
13 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,
|
|
VERIFY_BAN_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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""200 but extract_state returns None → None."""
|
|
monkeypatch.setattr(
|
|
"app.services.cian_session.extract_state",
|
|
lambda html, mfe, key: None,
|
|
)
|
|
result = _classify_verify_response(200, "<html></html>")
|
|
assert result is None
|
|
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_none(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""200 + extract_state returns None → None."""
|
|
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 None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_session_uses_chrome120_impersonate() -> None:
|
|
"""curl_cffi AsyncSession must be constructed with impersonate='chrome120'."""
|
|
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") == "chrome120"
|