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:
parent
2d4daceb2f
commit
dcfea2ad39
1 changed files with 82 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue