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: