test(mera/b2c): пинит kwargs делегации анонимного расчёта, а не факт вызова

Весь анти-абузный контур публичной ручки (анонимная квота cookie+IP, семафор,
503 вместо 502, consent-гейт) держится на одном аргументе: в
app.api.v1.trade_in.estimate уходит x_authenticated_user=None. Проверял это
ноль тестов: estimate везде замокан AsyncMock, который принимает любую
сигнатуру, — подмена None на чтение заголовка запроса оставляла все 14 тестов
зелёными, а публичная форма начинала считать от чужого имени мимо квоты.

Новый тест шлёт запрос С заголовком X-Authenticated-User: admin и сверяет
фактические await_args.kwargs; заодно требует, чтобы аргументы ехали по имени
(позиционный вызов обесценивает сверку) и чтобы аргумент вообще присутствовал
(дефолт эстиматора — чужая гарантия, не наша). Проверено падением: подмена на
request.headers.get даёт «пришло: 'admin'».

Там же UPDATE токена: параметры сверялись только по хэшу, id строки — нет.
Теперь пинится result.estimate_id: токен обязан вешаться на только что
посчитанную оценку. Проверено подменой параметра — красный по значению.

test_read_filters_by_expiry_and_hash оставлен текстовым: живого Postgres с
миграцией 278 здесь нет, а поведенческий тест, ни разу не прогнанный, — это
ещё один зелёный по построению. Вместо этого в самом тесте написано, что он
проверяет (предикат есть в тексте SQL, в параметрах хэш) и чего НЕ проверяет
(сессия — MagicMock, запрос не исполняется, протухший токен не отсекается), и
чем его заменить, когда БД появится.
This commit is contained in:
bot-backend 2026-08-29 19:06:25 +05:00
parent 2d4daceb2f
commit dcfea2ad39

View file

@ -15,6 +15,15 @@
4. БЕСПЛАТНАЯ ЧАСТЬ. Публичный ответ не содержит ни одного платного поля.
Проверяется набором ключей на равенство: любое добавленное поле роняет
тест, в том числе случайно добавленное платное.
5. ДЕЛЕГАЦИЯ. Весь анти-абузный рассказ ручки (анонимная квота на связку
cookie+IP, семафор одновременности, 503 вместо 502, consent-гейт) держится
ровно на ОДНОМ факте: в `app.api.v1.trade_in.estimate` уходит
`x_authenticated_user=None`. `AsyncMock` съедает любую сигнатуру, поэтому
«расчёт вызвали» тут ничего не значит проверяются фактические kwargs
вызова, причём запрос идёт С заголовком `X-Authenticated-User`: подмена
`None` на чтение заголовка обязана красить тест, иначе публичная ручка
молча начнёт считать чужим пользователем и мимо анонимной квоты.
"""
from __future__ import annotations
@ -61,6 +70,7 @@ _FAKE_COVERAGE = CoverageProbeResponse(
)
_TOKEN_EXPIRES = datetime(2026, 9, 5, 12, 0, tzinfo=UTC)
_FAKE_ESTIMATE_ID = "11111111-1111-1111-1111-111111111111"
def _fake_estimate_result() -> MagicMock:
@ -68,7 +78,7 @@ def _fake_estimate_result() -> MagicMock:
хендлеру нужны четыре атрибута, а перечисление всех полей платной модели
здесь означало бы, что тест придётся править при каждой правке эстиматора."""
result = MagicMock()
result.estimate_id = "11111111-1111-1111-1111-111111111111"
result.estimate_id = _FAKE_ESTIMATE_ID
result.n_analogs = 27
result.target_lat = 56.838
result.target_lon = 60.597
@ -187,9 +197,29 @@ def test_estimate_stores_token_hash_not_token(
params = db.execute.call_args_list[0].args[1]
assert token not in str(params), "сам токен уехал в БД — дамп базы открывает чужие расчёты"
assert params["token_hash"] == hashlib.sha256(token.encode()).hexdigest()
assert params["id"] == _FAKE_ESTIMATE_ID, (
"токен вешается не на ту строку: UPDATE обязан идти по id ТОЛЬКО ЧТО "
"посчитанной оценки (result.estimate_id), иначе ссылка либо ведёт в чужой "
"расчёт, либо не ведёт никуда"
)
def test_read_filters_by_expiry_and_hash(client: TestClient, db: MagicMock, flag_on: None) -> None:
"""ЧТО проверено: в тексте отправленного SQL присутствует предикат срока жизни,
а в параметры уехал ХЭШ токена, не сам токен.
ЧЕГО НЕ проверено: что протухший токен действительно не читается. Сессия здесь
MagicMock, запрос не исполняется: SQL не разбирается, `fetchone()` отдаёт строку
из фикстуры независимо от WHERE. Поэтому зелёными останутся, например, предикат,
перенесённый туда, где он ничего не отсекает, сравнение NOW() с полем не того
типа и любая ошибка в самом сравнении текст-то совпадает.
Поведенческой версии нет намеренно: она требует живого Postgres с миграцией 278
(образец self-skip-теста tests/test_purge_expired_trade_in_data.py::_live_session),
а писать её вслепую, ни разу не прогнав, значит завести ещё один тест, зелёный
по построению. Появится доступный Postgres этот тест заменяется на вставку
двух строк (свежий токен и просроченный) с проверкой 200 против 404.
"""
token = "t" * 40
with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE):
resp = client.post(f"{PREFIX}/estimate/read", json={"token": token})
@ -277,7 +307,57 @@ def test_read_returns_same_free_shape(client: TestClient, flag_on: None) -> None
assert resp.json()["n_analogs"] == 27
# ── 5. Бюджеты ───────────────────────────────────────────────────────────────
# ── 5. Делегация в закрытый контур ───────────────────────────────────────────
def test_estimate_delegates_anonymously_despite_auth_header(
client: TestClient, db: MagicMock, flag_on: None
) -> None:
"""Единственный тест, который смотрит НА АРГУМЕНТЫ делегации, а не на факт вызова.
Запрос идёт с `X-Authenticated-User: admin` то есть ровно тем заголовком,
который в закрытом контуре означает «это авторизованный пользователь». Ручка
обязана всё равно позвать эстиматор анонимом: `x_authenticated_user=None`
включает там consent-гейт и анонимную квоту на связку cookie+IP. Пробрось
сюда заголовок и публичная форма начнёт считать от чужого имени в обход
квоты, а все остальные тесты этого файла останутся зелёными: `AsyncMock`
принимает любую сигнатуру.
"""
with (
patch.object(
public_mera, "estimate", AsyncMock(return_value=_fake_estimate_result())
) as estimate_mock,
patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE),
):
resp = client.post(
f"{PREFIX}/estimate", json=_BODY, headers={"X-Authenticated-User": "admin"}
)
assert resp.status_code == 200, resp.text
call = estimate_mock.await_args
assert call is not None, "делегации не было вовсе"
assert call.args == (), (
"аргументы поехали позиционно — сверка по именам ниже перестала что-либо "
"проверять; зови эстиматор с ключевыми словами"
)
assert "x_authenticated_user" in call.kwargs, (
"аргумент исчез из вызова: у эстиматора он по умолчанию None, но тогда "
"гарантия анонимности держится на чужом дефолте, а не на этой ручке"
)
assert call.kwargs["x_authenticated_user"] is None, (
"публичная ручка передала пользователя в закрытый контур: consent-гейт и "
"анонимная квота выключаются, аноним считает от чужого имени "
f"(пришло: {call.kwargs['x_authenticated_user']!r})"
)
# Остальное едет тем же вызовом: сессия — та, что отдана зависимостью (иначе
# UPDATE токена ниже пишет в другую транзакцию), тело — то, что прислал аноним.
assert call.kwargs["db"] is db
assert call.kwargs["payload"].address == _BODY["address"]
assert call.kwargs["payload"].area_m2 == _BODY["area_m2"]
assert call.kwargs["payload"].rooms == _BODY["rooms"]
# ── 6. Бюджеты ───────────────────────────────────────────────────────────────
def test_estimate_rate_limited_per_ip(client: TestClient, flag_on: None) -> None: