From dcfea2ad3961a3039dedaa0012e72a6009603c93 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 19:06:25 +0500 Subject: [PATCH] =?UTF-8?q?test(mera/b2c):=20=D0=BF=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=20kwargs=20=D0=B4=D0=B5=D0=BB=D0=B5=D0=B3=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D0=BC=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82=D0=B0,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D1=84=D0=B0=D0=BA=D1=82=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Весь анти-абузный контур публичной ручки (анонимная квота 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, запрос не исполняется, протухший токен не отсекается), и чем его заменить, когда БД появится. --- .../tests/test_public_mera_estimate.py | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/backend/tests/test_public_mera_estimate.py b/tradein-mvp/backend/tests/test_public_mera_estimate.py index 26df13b2..5d844f49 100644 --- a/tradein-mvp/backend/tests/test_public_mera_estimate.py +++ b/tradein-mvp/backend/tests/test_public_mera_estimate.py @@ -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: