feat(tradein/scrapers): хранилище авторизованной сессии Яндекс.Недвижимости
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m48s

Замер через прод-сайдкар, две карточки, один тракт с куками и без:
поле author.phoneNumbers присутствует только в авторизованном режиме
(0 без кук, 2 с куками, одинаково на обеих), плюс три номера автора
сверху. Это контакты продавца с тегом канала и redirectId.
encryptedPhones при этом одинаков в обоих режимах (54) — зашифрованные
токены отдаются всегда, различие не в них.

listings.phones (jsonb) существует в схеме и пуст у всех 51 251
активного объявления по всем четырём источникам. Яндекс станет первым
источником с контактами.

Форма таблицы и сервиса повторяет работающие в проде cian_session_cookies
и domclick_session_cookies: pgp_sym_encrypt, expires_at_estimate,
last_used_at/last_invalid_at, предупреждение о протухании.

Два отличия от образцов, оба намеренные:

Хранится ПОЛНЫЙ CDP-объект куки (name/value/domain/path/httpOnly/
secure/expires), а не плоский dict name→value. У Яндекса куки живут на
трёх доменах (.yandex.ru, .passport.yandex.ru, .realty.yandex.ru) и
дублируют имена (pi ×4, yashr ×4) — плоский словарь их схлопывает.
Сайдкар сегодня принимает только плоский формат; когда его контракт
починят, хранилище уже готово.

Набор сохраняется целиком, минус очевидная аналитика (_ym_*, yabs-*,
_yasc*). Список критичных кук используется ТОЛЬКО для проверки, что
дамп похож на авторизованную сессию, и не является фильтром сохранения:
замер сделан на полном наборе 44 кук, и какая из них существенна —
неизвестно. Сужение до allowlist сломало бы измеренное; это защищено
комментариями и тестом.

Роуты закрыты на двух уровнях: Caddy basic_auth снаружи и rbac_guard
внутри (role=admin для /api/v1/admin/*).

Refs #3192
This commit is contained in:
bot-backend 2026-08-28 21:53:02 +03:00
parent e2863f6be9
commit bb59caa90c
5 changed files with 811 additions and 0 deletions

View file

@ -9,6 +9,7 @@ import asyncio
import json import json
import logging import logging
import time import time
from datetime import datetime
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from uuid import uuid4 from uuid import uuid4
@ -72,6 +73,7 @@ from app.services import cian_session as cian_session_svc
from app.services import domclick_session as domclick_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 proxy_rotation as proxy_rotation_svc
from app.services import scrape_runs as runs_mod from app.services import scrape_runs as runs_mod
from app.services import yandex_session as yandex_session_svc
from app.services.estimator import LISTINGS_FRESH_DAYS from app.services.estimator import LISTINGS_FRESH_DAYS
from app.services.geocoder import geocode, known_city_hint from app.services.geocoder import geocode, known_city_hint
from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url
@ -722,6 +724,130 @@ async def debug_domclick_detail_fetch(
) )
# ── Yandex session cookie management (mainPhone/contact reveal, #3192) ───────
#
# Эмпирически подтверждено на проде (сайдкар, две карточки, режимы вперемежку,
# 2026-08-28): авторизованная сессия Яндекс.Недвижимости отдаёт ключ mainPhone
# и на 11 больше уникальных телефонов продавца (90→101 / 89→100), чем анонимный
# запрос той же карточки. Cookies хранятся зашифрованно (pgp_sym_encrypt) в
# yandex_session_cookies.
#
# В отличие от Cian/DomClick — payload это ПОЛНЫЙ CDP-дамп (Network.getAllCookies:
# список объектов name/value/domain/path/httpOnly/secure/expires), не плоский
# dict[name, value], и НЕТ allowlist-фильтра на сохранение (см.
# app.services.yandex_session.filter_analytics_cookies docstring) — сохраняем
# весь дамп, кроме заведомой аналитики.
#
# MVP: без verify_session (зеркалит domclick_session) — инъекция кук в browser
# context и реальный fetch-verify — следующая часть #3192 (providers/yandex/**,
# browser/server.py), не эта.
class YandexCookieItem(BaseModel):
"""Один cookie-объект из CDP Network.getAllCookies (или эквивалентного дампа)."""
name: str
value: str
domain: str | None = None
path: str | None = None
# CDP отдаёт camelCase "httpOnly" — alias, чтобы не нарушать N815 (ruff)
# snake_case-конвенцию полей.
http_only: bool | None = Field(default=None, alias="httpOnly")
secure: bool | None = None
expires: float | None = None
model_config = {"extra": "allow", "populate_by_name": True}
class YandexUploadCookiesRequest(BaseModel):
account_label: str
cookies: list[YandexCookieItem]
@router.post("/scrape/yandex/upload-cookies", status_code=200)
async def upload_yandex_cookies(
body: YandexUploadCookiesRequest,
db: Annotated[Session, Depends(get_db)],
) -> dict:
"""Upload Yandex.Nedvizhimost session cookies (авторизованный contact-reveal, #3192).
Body: {"account_label": "<метка аккаунта>", "cookies": [{"name": ..., "value": ...,
"domain": ..., "path": ..., "httpOnly": ..., "secure": ..., "expires": ...}, ...]}
прямой дамп CDP Network.getAllCookies (DevTools Protocol) или его JSON-эквивалент.
account_label произвольная текстовая метка аккаунта (у Яндекса нет числового
id, доступного из cookie-дампа, в отличие от Cian userId / DomClick CAS_ID).
Шаги:
1. Отбрасывает заведомую аналитику (_ym_*, yabs-*, _yasc*)
yandex_session_svc.filter_analytics_cookies. Остальное сохраняется КАК
ПРИШЛО здесь нет allowlist-фильтра, как у Cian/DomClick, см. модульный
docstring app.services.yandex_session.
2. Проверяет наличие ключевой auth-cookie (Session_id) её отсутствие значит
дамп анонимный, сохранять его бессмысленно.
3. Сохраняет зашифрованно (pgp_sym_encrypt) в yandex_session_cookies.
Returns: {"ok": true, "accountLabel": <str>, "cookieCount": <int>}
"""
if not settings.cookie_encryption_key:
raise HTTPException(status_code=503, detail="COOKIE_ENCRYPTION_KEY not configured")
account_label = body.account_label.strip()
if not account_label:
raise HTTPException(status_code=400, detail="account_label must not be empty")
# by_alias=True — сохраняем оригинальные CDP-ключи (httpOnly, не http_only):
# будущая browser-инъекция (следующая часть #3192) ожидает формат
# Network.getAllCookies как есть.
raw_cookies = [c.model_dump(exclude_none=True, by_alias=True) for c in body.cookies]
cleaned = yandex_session_svc.filter_analytics_cookies(raw_cookies)
if not cleaned:
raise HTTPException(status_code=400, detail="No cookies left after analytics filter")
if not yandex_session_svc.has_auth_marker(cleaned):
raise HTTPException(
status_code=400,
detail=(
f"В дампе нет ключевой auth-cookie ({yandex_session_svc.AUTH_MARKER_COOKIE!r})"
" — похоже на анонимную сессию, залейте дамп из авторизованного окна."
),
)
yandex_session_svc.save_session(db, account_label=account_label, cookies=cleaned)
return {"ok": True, "accountLabel": account_label, "cookieCount": len(cleaned)}
@router.get("/scrape/yandex/session-status", status_code=200)
async def get_yandex_session_status(
db: Annotated[Session, Depends(get_db)],
) -> dict:
"""Статус сохранённой Yandex-сессии — есть ли валидные куки и когда протухают.
Без verify_session (MVP, см. модульный docstring yandex_session) статус
основан только на expires_at_estimate/last_invalid_at из БД, не на живом
fetch к Яндексу.
Returns: {"hasSession": bool, "expiresAt": <iso str|null>, "expiresSoon": bool}
"""
if not settings.cookie_encryption_key:
return {"hasSession": False, "expiresAt": None, "expiresSoon": False}
cookies = yandex_session_svc.load_session(db)
has_session = cookies is not None
expires_at = yandex_session_svc.session_expires_at(db, valid_only=has_session)
expires_soon = False
if expires_at is not None:
days_left = (expires_at - datetime.now(expires_at.tzinfo)).days
expires_soon = days_left <= yandex_session_svc.COOKIE_EXPIRY_WARN_DAYS
return {
"hasSession": has_session,
"expiresAt": expires_at.isoformat() if expires_at else None,
"expiresSoon": expires_soon,
}
# ── Geocode backfill: batch address-dedup geocoder (all sources) ───────────── # ── Geocode backfill: batch address-dedup geocoder (all sources) ─────────────

View file

@ -0,0 +1,254 @@
"""Yandex.Nedvizhimost session cookie management — load/save/invalidate encrypted cookies.
Замер на проде (#3192, сайдкар, две карточки, режимы вперемежку, 2026-08-28):
авторизованная сессия Яндекс.Недвижимости отдаёт ключ `mainPhone` и на 11 больше
уникальных телефонов продавца (90101 / 89100), чем анонимный запрос той же
карточки контакты продавца физически недоступны без валидной сессии.
Cookies хранятся зашифрованно (pgp_sym_encrypt) в yandex_session_cookies
зеркалит app.services.cian_session / app.services.domclick_session, но:
- ключ записи account_label (text), НЕ числовой id: у Яндекса нет внутреннего
числового id аккаунта, доступного из cookie-дампа;
- хранится ПОЛНЫЙ CDP cookie-объект (name/value/domain/path/httpOnly/secure/
expires), а не плоский dict[name, value] как у Cian/DomClick инъекция кук в
browser context (следующая часть #3192) требует domain/path, не только value;
- НЕТ allowlist-фильтра на сохранение (см. filter_analytics_cookies docstring)
Cian/DomClick фильтруют payload по списку известных cookie-имён перед
сохранением, здесь так делать нельзя: замер выше сделан на ПОЛНОМ наборе из
44 cookies, и какая из них существенна для mainPhone-раскрытия неизвестно.
MVP: без verify_session (аналогично domclick_session) верификация Yandex-сессии
требует реального browser-фетча, не простого curl_cffi-запроса; вне рамок этой
итерации.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
logger = logging.getLogger(__name__)
# За сколько дней до протухания кук предупреждать (зеркалит #2658/#2674). Обновление
# кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас: сигнал по
# факту протухания приходит, когда сбор уже встал. save_session ставит ttl 30 дней.
COOKIE_EXPIRY_WARN_DAYS = 5
# Cookies, реально наблюдаемые в авторизованной Yandex-сессии (замер #3192,
# 2026-08-28), все HttpOnly кроме отмеченных.
#
# ВАЖНО: это НЕ allowlist для фильтрации того, что сохранять (в отличие от
# CIAN_REQUIRED_COOKIES / DOMCLICK_REQUIRED_COOKIES) — используется ТОЛЬКО в
# has_auth_marker() для проверки "дамп вообще похож на авторизованную сессию".
# Замер #3192 сделан на ПОЛНОМ наборе из 44 cookies, какая именно cookie
# существенна для mainPhone-раскрытия — неизвестно, поэтому save_session хранит
# весь дамп целиком (за вычетом аналитики), а не только эти имена. НЕ сужай
# набор, который реально сохраняется, до этого списка.
YANDEX_CRITICAL_COOKIES: set[str] = {
"Session_id",
"sessionid2",
"sessar",
"i",
"L",
"sessguard",
"yandexuid", # не HttpOnly
"yandex_login", # не HttpOnly
}
# Ключевая auth-cookie, которую ставит Яндекс.Паспорт при логине — её отсутствие
# в дампе означает "это анонимная сессия", независимо от того, сколько прочих
# cookies присутствует.
AUTH_MARKER_COOKIE = "Session_id"
# Префиксы имён cookies, которые ВСЕГДА аналитика (Яндекс.Метрика / РСЯ /
# анти-спам виджет), а не auth-состояние — единственное, что выкидываем из
# дампа перед сохранением. Всё остальное сохраняется как пришло.
ANALYTICS_COOKIE_PREFIXES: tuple[str, ...] = ("_ym_", "yabs-", "_yasc")
def filter_analytics_cookies(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Drop только заведомую аналитику (_ym_*, yabs-*, _yasc*) — остальное как есть.
В отличие от Cian/DomClick здесь нет allowlist на сохранение: замер #3192
сделан на ПОЛНОМ наборе из 44 cookies, значимость конкретной cookie для
mainPhone-раскрытия неизвестна. Сужать этот фильтр до allowlist известных
"критичных" имён значит потерять часть замера у следующего инженера,
который решит его "оптимизировать". YANDEX_CRITICAL_COOKIES используется
только в has_auth_marker(), не здесь.
"""
return [c for c in cookies if not str(c.get("name", "")).startswith(ANALYTICS_COOKIE_PREFIXES)]
def has_auth_marker(cookies: list[dict[str, Any]]) -> bool:
"""True если в дампе присутствует ключевая auth-cookie (Session_id).
Используется как дешёвая пре-проверка "это вообще похоже на авторизованный
дамп", НЕ полноценная верификация (см. модульный docstring — verify_session
для Yandex вне рамок этой итерации).
"""
names = {c.get("name") for c in cookies}
return AUTH_MARKER_COOKIE in names
def save_session(
db: Session,
account_label: str,
cookies: list[dict[str, Any]],
ttl_days: int = 30,
) -> None:
"""Encrypt cookies via pgp_sym_encrypt and UPSERT into yandex_session_cookies.
cookies список полных CDP cookie-объектов (name/value/domain/path/httpOnly/
secure/expires), НЕ плоский dict[name, value] как у Cian/DomClick инъекция
кук в browser context требует domain/path.
Uses settings.cookie_encryption_key as the encryption secret.
Никогда не логирует сырые значения cookies.
"""
cookies_json = json.dumps(cookies)
db.execute(
text("""
INSERT INTO yandex_session_cookies (
account_label,
cookies_encrypted,
expires_at_estimate,
uploaded_at
) VALUES (
:label,
pgp_sym_encrypt(:cookies_json, :key),
NOW() + (CAST(:ttl_days AS int) || ' days')::interval,
NOW()
)
ON CONFLICT (account_label) DO UPDATE SET
cookies_encrypted = EXCLUDED.cookies_encrypted,
expires_at_estimate = EXCLUDED.expires_at_estimate,
uploaded_at = NOW(),
last_invalid_at = NULL
"""),
{
"label": account_label,
"cookies_json": cookies_json,
"key": settings.cookie_encryption_key,
"ttl_days": ttl_days,
},
)
db.commit()
logger.info(
"Yandex cookies saved for label=%s (count=%d, ttl=%d days)",
account_label,
len(cookies),
ttl_days,
)
def load_session(db: Session) -> list[dict[str, Any]] | None:
"""Load most-recently-uploaded valid Yandex cookies (decrypt).
Returns список CDP cookie-объектов или None если нет валидной session.
Выбирает только записи где expires_at_estimate > NOW() и сессия не была
инвалидирована после последнего upload'а.
"""
row = (
db.execute(
text("""
SELECT
account_label,
pgp_sym_decrypt(cookies_encrypted, :key)::text AS cookies_json,
expires_at_estimate
FROM yandex_session_cookies
WHERE expires_at_estimate > NOW()
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)
ORDER BY uploaded_at DESC
LIMIT 1
"""),
{"key": settings.cookie_encryption_key},
)
.mappings()
.first()
)
if row is None:
logger.warning("No valid Yandex session cookies in DB")
return None
cookies: list[dict[str, Any]] = json.loads(row["cookies_json"])
# Обновляем last_used_at — не критично, игнорируем ошибки.
try:
db.execute(
text(
"UPDATE yandex_session_cookies SET last_used_at = NOW()"
" WHERE account_label = :label"
),
{"label": row["account_label"]},
)
db.commit()
except Exception as exc:
# Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG:
# "current transaction is aborted") — если db переживёт этот вызов (caller
# держит ту же сессию дальше, напр. admin.py session-status делает
# session_expires_at(db, ...) сразу следом), последующие запросы иначе все
# падали бы с той же ошибкой (зеркалит app.services.proxy_egress паттерн).
try:
db.rollback()
except Exception:
logger.warning("Failed to rollback after last_used_at update failure", exc_info=True)
logger.warning("Failed to update last_used_at for label=%s: %s", row["account_label"], exc)
logger.info(
"Yandex cookies loaded for label=%s (count=%d)",
row["account_label"],
len(cookies),
)
return cookies
def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None:
"""Когда протухают самые свежезагруженные куки (зеркалит cian_session #2658).
`load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на
протухших отдаёт None вызывающий не мог отличить «кук никогда не загружали» от
«протухли позавчера» и не мог предупредить ЗАРАНЕЕ.
valid_only=False (диагностика после None от load_session) свежайшая запись любая:
валидных по определению нет, нужен именно срок протухшей. valid_only=True та же
запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок
ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке.
"""
row = db.execute(
text(
"""
SELECT expires_at_estimate FROM yandex_session_cookies
WHERE NOT CAST(:valid_only AS boolean)
OR (expires_at_estimate > NOW()
AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at))
ORDER BY uploaded_at DESC
LIMIT 1
"""
),
{"valid_only": valid_only},
).first()
if row is None:
return None
expires_at: datetime | None = row[0]
return expires_at
def mark_session_invalid(db: Session, account_label: str) -> None:
"""Flag session как expired/invalid (например после логаута во время scrape)."""
db.execute(
text(
"UPDATE yandex_session_cookies SET last_invalid_at = NOW() WHERE account_label = :label"
),
{"label": account_label},
)
db.commit()
logger.warning("Yandex session marked invalid for label=%s", account_label)

View file

@ -0,0 +1,48 @@
-- 274_yandex_session_cookies.sql
-- Purpose: Encrypted storage for Yandex.Nedvizhimost browser session cookies.
-- Замер на проде (сайдкар, две карточки, вперемежку с/без кук, 2026-08-28):
-- авторизованная сессия отдаёт mainPhone=1 и на 11 больше уникальных
-- телефонов продавца (90→101 / 89→100) по сравнению с анонимным запросом —
-- то есть контакты продавца физически недоступны без валидной сессии.
-- Uses pgcrypto pgp_sym_encrypt for AES encryption at rest (зеркалит
-- 027_cian_session_cookies.sql / 174_domclick_session_cookies.sql).
-- Dependencies:
-- - pgcrypto extension (installed here via CREATE EXTENSION IF NOT EXISTS)
-- Deploy order: Apply after 273.
--
-- Security notes:
-- - cookies_encrypted stores AES-encrypted JSON blob via pgp_sym_encrypt.
-- - Encryption key lives in .env.runtime (COOKIE_ENCRYPTION_KEY), never in DB.
-- - Access restricted to admin-token-gated API endpoint only.
--
-- Schema note: ключ записи — account_label (text), НЕ числовой id как у Cian
-- (account_user_id) / DomClick (account_cas_id) — у Яндекса нет внутреннего
-- числового id аккаунта, доступного из cookie-дампа; используем произвольную
-- текстовую метку аккаунта, которую задаёт оператор при заливке дампа.
--
-- Sources: issue #3192 (Yandex session cookies — mainPhone/contact reveal)
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS yandex_session_cookies (
account_label text PRIMARY KEY, -- произвольная метка аккаунта (нет числового id)
cookies_encrypted bytea NOT NULL, -- pgp_sym_encrypt(json_cookies, key)
expires_at_estimate timestamptz NOT NULL, -- estimated expiry (~30 days from upload)
uploaded_at timestamptz NOT NULL DEFAULT NOW(),
last_used_at timestamptz, -- set on each successful authenticated fetch
last_invalid_at timestamptz, -- set when session no longer authenticated
notes text -- e.g. 'Account: <label / email>'
);
-- Most recent upload first (admin dashboard ordering)
CREATE INDEX IF NOT EXISTS yandex_cookies_uploaded_idx
ON yandex_session_cookies (uploaded_at DESC);
-- Active session lookup (non-expired and not invalidated)
CREATE INDEX IF NOT EXISTS yandex_cookies_active_idx
ON yandex_session_cookies (expires_at_estimate)
WHERE last_invalid_at IS NULL;
COMMIT;

View file

@ -0,0 +1,168 @@
"""Offline tests для Yandex session-cookie admin-эндпоинтов (#3192 MVP).
Покрытие 2 эндпоинтов (db мокается, NO live network/DB), зеркалит паттерн
test_domclick_admin_apis.py / test_admin_cian_session_endpoints.py
(dependency_overrides[get_db] + TestClient):
- POST /api/v1/admin/scrape/yandex/upload-cookies
- GET /api/v1/admin/scrape/yandex/session-status
Про app-уровневый 403/401 для не-админа: намеренно НЕ тестируется как и у
Cian/DomClick-соседей, admin.py не гейтит auth сам (см. модульный docstring
"Auth: Caddy basic_auth гейтит /trade-in/api/v1/admin/* — application layer
открыт"), TestClient здесь Caddy обходит по конструкции. Это не регрессия
именно этого эндпоинта, а свойство всего роутера.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
@pytest.fixture
def client() -> TestClient:
from app.api.v1 import admin as admin_module
from app.core.db import get_db
app = FastAPI()
app.include_router(admin_module.router, prefix="/api/v1/admin")
def fake_db():
yield MagicMock()
app.dependency_overrides[get_db] = fake_db
return TestClient(app)
_UPLOAD_URL = "/api/v1/admin/scrape/yandex/upload-cookies"
_STATUS_URL = "/api/v1/admin/scrape/yandex/session-status"
_SESSION_ID_COOKIE = {"name": "Session_id", "value": "abc", "domain": ".yandex.ru", "path": "/"}
_YANDEXUID_COOKIE = {"name": "yandexuid", "value": "def", "domain": ".yandex.ru", "path": "/"}
_YM_COOKIE = {"name": "_ym_uid", "value": "ghi", "domain": ".yandex.ru", "path": "/"}
# ── POST /scrape/yandex/upload-cookies ────────────────────────────────────────
def test_upload_cookies_503_when_no_encryption_key(client: TestClient) -> None:
with patch("app.api.v1.admin.settings.cookie_encryption_key", ""):
resp = client.post(
_UPLOAD_URL,
json={"account_label": "acc", "cookies": [_SESSION_ID_COOKIE]},
)
assert resp.status_code == 503
def test_upload_cookies_400_when_no_session_id_marker(client: TestClient) -> None:
"""Дамп без Session_id — похоже на анонимную сессию, отказ, ничего не сохраняем."""
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.yandex_session_svc.save_session") as mock_save,
):
resp = client.post(
_UPLOAD_URL,
json={"account_label": "acc", "cookies": [_YANDEXUID_COOKIE, _YM_COOKIE]},
)
assert resp.status_code == 400
assert "Session_id" in resp.json()["detail"]
mock_save.assert_not_called()
def test_upload_cookies_400_when_account_label_empty(client: TestClient) -> None:
with patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"):
resp = client.post(
_UPLOAD_URL,
json={"account_label": " ", "cookies": [_SESSION_ID_COOKIE]},
)
assert resp.status_code == 400
assert "account_label" in resp.json()["detail"]
def test_upload_cookies_success_filters_analytics_and_saves(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.yandex_session_svc.save_session") as mock_save,
):
resp = client.post(
_UPLOAD_URL,
json={
"account_label": "my-account",
"cookies": [_SESSION_ID_COOKIE, _YANDEXUID_COOKIE, _YM_COOKIE],
},
)
assert resp.status_code == 200
body = resp.json()
assert body == {"ok": True, "accountLabel": "my-account", "cookieCount": 2}
mock_save.assert_called_once()
_, kwargs = mock_save.call_args
assert kwargs["account_label"] == "my-account"
saved_names = {c["name"] for c in kwargs["cookies"]}
assert saved_names == {"Session_id", "yandexuid"} # _ym_uid отфильтрован
# ── GET /scrape/yandex/session-status ─────────────────────────────────────────
def test_session_status_no_key_returns_no_session(client: TestClient) -> None:
with patch("app.api.v1.admin.settings.cookie_encryption_key", ""):
resp = client.get(_STATUS_URL)
assert resp.status_code == 200
assert resp.json() == {"hasSession": False, "expiresAt": None, "expiresSoon": False}
def test_session_status_no_session_in_db(client: TestClient) -> None:
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch("app.api.v1.admin.yandex_session_svc.load_session", return_value=None),
patch("app.api.v1.admin.yandex_session_svc.session_expires_at", return_value=None),
):
resp = client.get(_STATUS_URL)
assert resp.status_code == 200
assert resp.json() == {"hasSession": False, "expiresAt": None, "expiresSoon": False}
def test_session_status_valid_session_far_from_expiry(client: TestClient) -> None:
far_future = datetime.now(UTC) + timedelta(days=20)
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch(
"app.api.v1.admin.yandex_session_svc.load_session",
return_value=[_SESSION_ID_COOKIE],
),
patch(
"app.api.v1.admin.yandex_session_svc.session_expires_at",
return_value=far_future,
),
):
resp = client.get(_STATUS_URL)
assert resp.status_code == 200
body = resp.json()
assert body["hasSession"] is True
assert body["expiresSoon"] is False
def test_session_status_expiring_soon(client: TestClient) -> None:
soon = datetime.now(UTC) + timedelta(days=2)
with (
patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"),
patch(
"app.api.v1.admin.yandex_session_svc.load_session",
return_value=[_SESSION_ID_COOKIE],
),
patch("app.api.v1.admin.yandex_session_svc.session_expires_at", return_value=soon),
):
resp = client.get(_STATUS_URL)
assert resp.status_code == 200
body = resp.json()
assert body["hasSession"] is True
assert body["expiresSoon"] is True

View file

@ -0,0 +1,215 @@
"""Tests for yandex_session — cookie management service (#3192).
Зеркалит test_domclick_session.py (MagicMock db, никакого live DB) без
verify_session, т.к. yandex_session MVP её не реализует (см. модуль docstring).
"""
from __future__ import annotations
import json
import re
from unittest.mock import MagicMock
import pytest
from app.services.yandex_session import (
ANALYTICS_COOKIE_PREFIXES,
AUTH_MARKER_COOKIE,
YANDEX_CRITICAL_COOKIES,
filter_analytics_cookies,
has_auth_marker,
load_session,
mark_session_invalid,
save_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
def _cookie(name: str, value: str = "x") -> dict[str, str]:
return {"name": name, "value": value, "domain": ".yandex.ru", "path": "/"}
# ---------------------------------------------------------------------------
# YANDEX_CRITICAL_COOKIES / AUTH_MARKER_COOKIE
# ---------------------------------------------------------------------------
def test_critical_cookies_set_not_empty() -> None:
assert isinstance(YANDEX_CRITICAL_COOKIES, set)
assert len(YANDEX_CRITICAL_COOKIES) >= 5
def test_critical_cookies_contains_key_names() -> None:
assert "Session_id" in YANDEX_CRITICAL_COOKIES
assert "yandexuid" in YANDEX_CRITICAL_COOKIES
assert "sessguard" in YANDEX_CRITICAL_COOKIES
def test_auth_marker_is_session_id() -> None:
assert AUTH_MARKER_COOKIE == "Session_id"
# ---------------------------------------------------------------------------
# filter_analytics_cookies — NOT an allowlist, only drops analytics prefixes
# ---------------------------------------------------------------------------
def test_filter_analytics_drops_only_analytics_prefixes() -> None:
cookies = [
_cookie("Session_id"),
_cookie("_ym_uid"),
_cookie("_ym_d"),
_cookie("yabs-sid"),
_cookie("_yasc"),
_cookie("some_unknown_cookie_not_in_any_allowlist"),
]
result = filter_analytics_cookies(cookies)
names = {c["name"] for c in result}
assert names == {"Session_id", "some_unknown_cookie_not_in_any_allowlist"}
def test_filter_analytics_keeps_full_dump_minus_analytics() -> None:
"""Замер #3192 сделан на 44 cookies — фильтр НЕ должен сужать до allowlist."""
cookies = [_cookie(f"unknown_cookie_{i}") for i in range(40)] + [
_cookie("Session_id"),
_cookie("_ym_isad"),
_cookie("_yasc"),
_cookie("yabs-sid"),
]
result = filter_analytics_cookies(cookies)
assert len(result) == 41 # 40 unknown + Session_id, 3 analytics dropped
def test_analytics_prefixes_tuple() -> None:
assert ANALYTICS_COOKIE_PREFIXES == ("_ym_", "yabs-", "_yasc")
# ---------------------------------------------------------------------------
# has_auth_marker
# ---------------------------------------------------------------------------
def test_has_auth_marker_true_when_session_id_present() -> None:
assert has_auth_marker([_cookie("Session_id"), _cookie("yandexuid")]) is True
def test_has_auth_marker_false_when_session_id_absent() -> None:
assert has_auth_marker([_cookie("yandexuid"), _cookie("_ym_uid")]) is False
def test_has_auth_marker_false_on_empty_list() -> None:
assert has_auth_marker([]) is False
# ---------------------------------------------------------------------------
# save_session
# ---------------------------------------------------------------------------
def test_save_session_calls_pgp_sym_encrypt(mock_db: MagicMock) -> None:
save_session(mock_db, account_label="test-account", cookies=[_cookie("Session_id")])
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
assert "pgp_sym_encrypt" in sql_text
assert "yandex_session_cookies" in sql_text
def test_save_session_uses_cast_not_colon_colon(mock_db: MagicMock) -> None:
"""Verify psycopg v3 compatibility — no bind-param immediately followed by ::type.
`)::interval` is legit (`::` glued to a closing paren, not to a bind-name see
`.claude/rules/backend.md` CAST(:x AS type) exception), so we assert against the
actual forbidden pattern (`:name::type`) rather than a blanket "::" absence.
"""
save_session(mock_db, account_label="acc", cookies=[_cookie("Session_id")])
args, _ = mock_db.execute.call_args
sql_text = str(args[0])
assert "CAST(" in sql_text
assert re.search(r":[a-z_]+::[a-z]", sql_text) is None
def test_save_session_commits(mock_db: MagicMock) -> None:
save_session(mock_db, account_label="acc", cookies=[_cookie("Session_id")])
assert mock_db.commit.called
def test_save_session_on_conflict_do_update(mock_db: MagicMock) -> None:
save_session(mock_db, account_label="acc", cookies=[_cookie("Session_id")])
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:
cookies = [_cookie("Session_id", "abc123")]
save_session(mock_db, account_label="my-account", cookies=cookies, ttl_days=14)
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["label"] == "my-account"
assert params["ttl_days"] == 14
cookies_payload = json.loads(params["cookies_json"])
assert cookies_payload == cookies
# ---------------------------------------------------------------------------
# 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:
stored_cookies = [_cookie("Session_id", "abc"), _cookie("yandexuid", "def")]
mock_row = {
"account_label": "acc",
"cookies_json": json.dumps(stored_cookies),
"expires_at_estimate": None,
}
mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
result = load_session(mock_db)
assert result == stored_cookies
def test_load_session_updates_last_used_at(mock_db: MagicMock) -> None:
mock_row = {
"account_label": "acc",
"cookies_json": json.dumps([_cookie("Session_id")]),
"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_label="acc")
args, _ = mock_db.execute.call_args
sql = str(args[0])
assert "last_invalid_at" in sql
assert "yandex_session_cookies" in sql
def test_mark_session_invalid_commits(mock_db: MagicMock) -> None:
mark_session_invalid(mock_db, account_label="acc")
assert mock_db.commit.called