From cd0522ceaeb3036a17851f459645d4271f22f7f9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 19:11:48 +0500 Subject: [PATCH] =?UTF-8?q?fix(payments):=20=D0=BE=D0=B4=D0=B8=D0=BD=20?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D0=BE=D0=B9=20=D0=BF=D0=BB=D0=B0=D1=82=D1=91?= =?UTF-8?q?=D0=B6=20=D0=BD=D0=B0=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D1=83=20?= =?UTF-8?q?=E2=80=94=20=D0=B3=D0=B0=D1=80=D0=B0=D0=BD=D1=82=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=91=D0=94,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=8F=D0=B4=D0=BA=D0=B0=20=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Идемпотентность checkout держалась на «SELECT, потом INSERT» — ровно на том, что шапка модуля называет дефектом. Двойной клик по кнопке оплаты давал два параллельных запроса, два INSERT, два Init и два холда на карте покупателя. - миграция 277: частичный UNIQUE (estimate_id, product_code) по живым статусам + ON CONFLICT DO NOTHING в INSERT. Проигравший гонку не идёт в банк: отдаёт ссылку соперника, если та уже готова, иначе 409; - граница по времени для брошенных попыток: NEW/FORM_SHOWED старше 30 минут переводятся в DEADLINE_EXPIRED. Без неё зависший платёж (нотификации по нему может не прийти вовсе) навсегда отдавал покупателю одну и ту же протухшую PaymentURL. Окно НЕ распространяется на AUTHORIZED и прочие карточные статусы — там деньги уже в игре, разгребать их — работа реконсиляции; - IDOR: checkout читал оценку без _assert_estimate_access. По чужому estimate_id возвращался order_id чужого живого платежа, а order_id — право доступа для /payments/status/, отдающего capability-ссылку на отчёт. Проверка ставится только для оценок с владельцем: у анонимной покупки идентичности нет, правом там работает сам estimate_id. Тесты двусторонние, фальсификация прогнана: снятие ON CONFLICT / границы по времени / IDOR-гварда красит ровно один тест каждый раз, два из трёх — по значению ответа. --- tradein-mvp/backend/app/api/v1/payments.py | 148 ++++++++-- .../sql/277_payments_live_checkout_uidx.sql | 62 ++++ .../backend/tests/test_payments_router.py | 266 +++++++++++++++++- 3 files changed, 455 insertions(+), 21 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/277_payments_live_checkout_uidx.sql diff --git a/tradein-mvp/backend/app/api/v1/payments.py b/tradein-mvp/backend/app/api/v1/payments.py index 0c8076a8..49245969 100644 --- a/tradein-mvp/backend/app/api/v1/payments.py +++ b/tradein-mvp/backend/app/api/v1/payments.py @@ -12,11 +12,13 @@ Merge безопасен на выключенном контуре — на п терминала (`app/main.py` роняет старт, если включить без ключей). ── Идемпотентность держится на БД, а не на «проверить-потом-вставить» ──────── -Обе гонки, которые здесь реальны (банк шлёт AUTHORIZED и CONFIRMED -одновременно; банк ретраит нотификацию почасово сутки), закрыты UNIQUE-ключами -миграции 233 + `ON CONFLICT DO NOTHING`. Пара «SELECT, потом INSERT» здесь была -бы дефектом: между ними успевает пройти параллельный запрос, и выдача -происходит дважды. +Все три гонки, которые здесь реальны (двойной клик по кнопке оплаты; банк шлёт +AUTHORIZED и CONFIRMED одновременно; банк ретраит нотификацию почасово сутки), +закрыты UNIQUE-ключами миграций 233 и 277 + `ON CONFLICT DO NOTHING`. Пара +«SELECT, потом INSERT» здесь была бы дефектом: между ними успевает пройти +параллельный запрос, и выдача (или холд на карте) происходит дважды. SELECT +живого платежа в `checkout` остался, но только как быстрый путь для честного +повтора — гонку ловит не он, а `payments_live_estimate_product_uidx`. `payment_notifications.processed_at` — единственный признак «выдача состоялась». Наличие строки нотификации таким признаком НЕ является: процесс @@ -59,7 +61,11 @@ from pydantic import BaseModel, Field from sqlalchemy import text from sqlalchemy.orm import Session -from app.api.v1.trade_in import ESTIMATE_READABLE_SQL, load_estimate +from app.api.v1.trade_in import ( + ESTIMATE_READABLE_SQL, + _assert_estimate_access, + load_estimate, +) from app.core.config import settings from app.core.db import get_db from app.schemas.trade_in import AggregatedEstimate @@ -149,8 +155,38 @@ _PRE_CONFIRM_STATUSES = frozenset( # оценке обязан вернуть ту же ссылку, а не создавать второй холд на карте # покупателя. Терминальные (CANCELED/REJECTED/REFUNDED/...) сюда не входят — # после отказа человек вправе попробовать оплатить заново. +# +# Этот же список — предикат частичного UNIQUE(estimate_id, product_code) +# миграции 277, который и делает «один живой платёж» свойством БД, а не +# порядка выполнения. Расхождение кода и миграции ловит +# tests/test_payments_router.py::test_live_status_predicate_matches_code. _REUSABLE_STATUSES = _PRE_CONFIRM_STATUSES +# Статусы, из которых платёж НИКОГДА не выйдет сам: банк шлёт нотификации по +# исходу платежа, а не по факту «форма открыта», поэтому брошенный checkout +# остаётся в NEW/FORM_SHOWED навсегда. Без границы по времени покупатель +# получал бы на каждый повторный checkout одну и ту же ссылку — а она у банка +# уже протухла, и начать оплату заново становилось бы невозможно. +# +# Границу двигаем ТОЛЬКО по этим двум статусам. Всё, что дальше по цепочке +# (AUTHORIZING/AUTHORIZED/3DS_*/CHECKING/PROCESSING/CONFIRMING), означает, что +# карточный поток уже начался и деньги, возможно, захолдированы: пометить такое +# «просроченным» и выдать вторую ссылку — это и есть второй холд. Разгребать +# зависшие карточные статусы — работа реконсиляции (PR-E, GetState), а не +# checkout'а. +_ABANDONABLE_STATUSES = frozenset({"NEW", "FORM_SHOWED"}) + +# 30 минут. Обоснование срока: за это окно нельзя «случайно» не дойти до карты — +# ввод карты и 3DS укладываются в минуты, а как только поток начался, статус +# уходит из _ABANDONABLE_STATUSES и окно к платежу вообще не применяется. +# Значит, к моменту истечения окна карточный поток провабельно не начинался, и +# освободить пару (estimate_id, product_code) под новую попытку безопасно. +# Остаточный риск честно называем: если покупатель через час всё-таки дооплатит +# СТАРУЮ форму, банк подтвердит её (выдача состоится по ней — статус-машина +# ниже это переживает), а новая попытка так и останется неоплаченной; два +# списания требуют, чтобы человек намеренно оплатил обе формы. +_ABANDONED_AFTER_MINUTES = 30 + _ORDER_ID_PREFIX = "mera-" # 32 байта энтропии (43 символа base64url) — перебор capability-ссылки # неосуществим, а сама ссылка остаётся кликабельной в мессенджере. @@ -209,9 +245,15 @@ def checkout( ) -> CheckoutOut: """Создаёт платёж и возвращает `PaymentURL` формы Т-Банка. - Идемпотентность по своему `order_id`: живой платёж по той же паре - (estimate_id, product_code) переиспользуется вместе с уже полученным - `PaymentURL` — второй `Init` создал бы второй холд на карте покупателя. + Идемпотентность — свойство БД, а не порядка выполнения: частичный UNIQUE + (estimate_id, product_code) по живым статусам (миграция 277) физически не + даёт существовать двум живым платежам по одной оценке, а `ON CONFLICT DO + NOTHING` превращает проигрыш в гонке в ответ, а не во второй `Init` (и, + значит, во второй холд на карте покупателя). + + Три исхода: живой платёж с готовой ссылкой — 200 с ТОЙ ЖЕ ссылкой; параллельный + checkout ещё не дошёл до ответа банка — 409 (ретрай через секунду вернёт + ссылку); иначе создаём новый платёж. """ _require_enabled() @@ -232,26 +274,45 @@ def checkout( ).fetchone() if estimate is None: raise HTTPException(status_code=404, detail="estimate not found or expired") + if estimate.created_by is not None: + # Тот же IDOR-гвард (#690), что у остальных ручек по оценке. Он здесь не + # про «показать чужой отчёт напрямую», а про две другие двери: checkout + # по чужому estimate_id возвращает order_id чужого живого платежа (ветка + # переиспользования ниже), а order_id — это право доступа для + # /payments/status/, который отдаёт capability-ссылку на отчёт, + # как только владелец заплатит. + # + # Проверяем только оценки, у которых владелец ЕСТЬ. У анонимной покупки + # на meraocenka.ru идентичности нет (см. блок про capability-ссылку в + # шапке модуля), и правом там работает сам неугадываемый estimate_id — + # требовать заголовок означало бы сделать анонимный checkout + # невозможным, а не более безопасным. + _assert_estimate_access(estimate.created_by, x_authenticated_user) - existing = db.execute( + # Освобождаем пару (estimate_id, product_code) от брошенных попыток ДО + # проверки живого платежа: иначе и переиспользование вернуло бы мёртвую + # ссылку, и UNIQUE миграции 277 не дал бы создать новую (см. комментарий у + # _ABANDONED_AFTER_MINUTES). + db.execute( text( """ - SELECT order_id, payment_url, amount_kopecks, status - FROM payments + UPDATE payments + SET status = 'DEADLINE_EXPIRED', updated_at = NOW() WHERE estimate_id = CAST(:estimate_id AS uuid) AND product_code = :product_code - AND payment_url IS NOT NULL - AND status = ANY(CAST(:reusable AS text[])) - ORDER BY created_at DESC - LIMIT 1 + AND status = ANY(CAST(:abandonable AS text[])) + AND created_at < NOW() - make_interval(mins => CAST(:mins AS int)) """ ), { "estimate_id": str(payload.estimate_id), "product_code": payload.product_code, - "reusable": sorted(_REUSABLE_STATUSES), + "abandonable": sorted(_ABANDONABLE_STATUSES), + "mins": _ABANDONED_AFTER_MINUTES, }, - ).fetchone() + ) + + existing = _find_live_payment(db, payload) if existing is not None: return CheckoutOut( order_id=existing.order_id, @@ -266,7 +327,7 @@ def checkout( # нотификация найдёт платёж, а UNIQUE(order_id) — страховка от двойной # записи. 5 + 32 = 37 символов, влезает в CHECK(char_length <= 50). order_id = f"{_ORDER_ID_PREFIX}{uuid4().hex}" - db.execute( + inserted = db.execute( # fetchone() ДО commit(): курсор после коммита пуст text( """ INSERT INTO payments ( @@ -276,6 +337,8 @@ def checkout( :order_id, :terminal_key, :product_code, :amount, 'NEW', :created_by, CAST(:estimate_id AS uuid), :email, :phone ) + ON CONFLICT DO NOTHING + RETURNING order_id """ ), { @@ -288,9 +351,26 @@ def checkout( "email": payload.customer_email, "phone": payload.customer_phone, }, - ) + ).fetchone() db.commit() + if inserted is None: + # Гонку выиграл параллельный checkout (двойной клик). Своего Init не + # делаем ни при каких условиях — он и есть второй холд. + rival = _find_live_payment(db, payload) + if rival is not None: + return CheckoutOut( + order_id=rival.order_id, + payment_url=rival.payment_url, + amount_kopecks=rival.amount_kopecks, + status=rival.status, + ) + # Соперник вставил строку, но ответа банка ещё не получил: ссылки пока + # нет ни у кого. Честный 409 — придумать ссылку нечем, а ждать чужого + # Init внутри HTTP-цикла нельзя (его бюджет — до ~74 с). + logger.info("checkout: параллельный checkout ещё в полёте, estimate_id известен клиенту") + raise HTTPException(status_code=409, detail="checkout already in progress, retry shortly") + try: # asyncio.run в синхронном хендлере — тот же мост, что и # trade_in._try_revive_dead_estimate: Starlette гоняет `def`-хендлер в @@ -366,6 +446,34 @@ def checkout( ) +def _find_live_payment(db: Session, payload: CheckoutInput) -> Any: + """Живой платёж по этой оценке, у которого уже есть ссылка на форму. + + `payment_url IS NOT NULL` обязателен: строка без ссылки — это чужой checkout + в полёте (Init ещё не ответил), и отдавать по ней `payment_url=None` значило + бы соврать в схеме ответа. Такой случай — 409 у вызывающей стороны. + """ + return db.execute( + text( + """ + SELECT order_id, payment_url, amount_kopecks, status + FROM payments + WHERE estimate_id = CAST(:estimate_id AS uuid) + AND product_code = :product_code + AND payment_url IS NOT NULL + AND status = ANY(CAST(:reusable AS text[])) + ORDER BY created_at DESC + LIMIT 1 + """ + ), + { + "estimate_id": str(payload.estimate_id), + "product_code": payload.product_code, + "reusable": sorted(_REUSABLE_STATUSES), + }, + ).fetchone() + + def _build_receipt_or_none( item_name: str, amount_kopecks: int, payload: CheckoutInput ) -> dict[str, Any] | None: diff --git a/tradein-mvp/backend/data/sql/277_payments_live_checkout_uidx.sql b/tradein-mvp/backend/data/sql/277_payments_live_checkout_uidx.sql new file mode 100644 index 00000000..25d1d7b4 --- /dev/null +++ b/tradein-mvp/backend/data/sql/277_payments_live_checkout_uidx.sql @@ -0,0 +1,62 @@ +-- 277_payments_live_checkout_uidx.sql +-- Идемпотентность checkout на уровне БД: не больше одного ЖИВОГО платежа на +-- пару (estimate_id, product_code). +-- +-- ── WHY ────────────────────────────────────────────────────────────────────── +-- До этой миграции переиспользование живого платежа в +-- app/api/v1/payments.py::checkout держалось на «SELECT, потом INSERT» — ровно +-- на том, что шапка того же модуля называет дефектом. Обычный двойной клик по +-- кнопке оплаты даёт два параллельных запроса: оба проходят SELECT до того, как +-- любой из них вставит строку, оба делают INSERT, оба зовут Init — и на карте +-- покупателя появляются ДВА ХОЛДА. Порядок выполнения тут ничего не решает, +-- решает уникальный ключ: второй INSERT обязан отбиться самой БД. +-- +-- ── Почему частичный, а не полный UNIQUE(estimate_id, product_code) ────────── +-- Полный запретил бы повторную покупку навсегда: после CANCELED/REJECTED +-- человек вправе попробовать оплатить заново, а после CONFIRMED — купить +-- второй раз. Поэтому в предикате только «живые» статусы — те, при которых +-- платёжная сессия ещё не завершилась. Список = _REUSABLE_STATUSES +-- (= _PRE_CONFIRM_STATUSES) в app/api/v1/payments.py; расхождение ловит +-- tests/test_payments_router.py::test_live_status_predicate_matches_code — +-- индекс, который шире кода, запрещает легитимный повтор, а который уже — +-- пропускает второй холд. +-- +-- Предикат ссылается только на неизменяемые выражения (сравнение колонок с +-- литералами), поэтому индекс легален как частичный, а строка входит в него и +-- выходит автоматически при UPDATE статуса. +-- +-- estimate_id IS NOT NULL — обязательная часть предиката: колонка nullable +-- (FK ON DELETE SET NULL, 233_payments.sql), а обычный UNIQUE считает NULL +-- отличным от NULL, так что без этого условия строки с NULL просто копились бы +-- в индексе без пользы. +-- +-- ── Безопасность применения ───────────────────────────────────────────────── +-- Контур выключен (PAYMENTS_ENABLED=false), таблица на проде пуста +-- (проверено 2026-08-29: SELECT count(*) FROM payments → 0), поэтому обычный +-- CREATE UNIQUE INDEX не может упасть на существующих дублях и не блокирует +-- ничью запись. IF NOT EXISTS — повторное применение безвредно. + +BEGIN; + +CREATE UNIQUE INDEX IF NOT EXISTS payments_live_estimate_product_uidx + ON payments (estimate_id, product_code) + WHERE estimate_id IS NOT NULL + AND status IN ( + 'NEW', + 'FORM_SHOWED', + 'PREAUTHORIZING', + 'AUTHORIZING', + 'AUTHORIZED', + '3DS_CHECKING', + '3DS_CHECKED', + 'CHECKING', + 'CHECKED', + 'PROCESSING', + 'CONFIRMING' + ); + +COMMENT ON INDEX payments_live_estimate_product_uidx IS + 'Не больше одного живого платежа на (estimate_id, product_code): двойной ' + 'клик по кнопке оплаты не создаёт второй холд на карте покупателя.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_payments_router.py b/tradein-mvp/backend/tests/test_payments_router.py index cdedc43f..e85b677c 100644 --- a/tradein-mvp/backend/tests/test_payments_router.py +++ b/tradein-mvp/backend/tests/test_payments_router.py @@ -19,7 +19,15 @@ kill-switch, capability-ссылка. - убрать его же из INSERT в `payment_notifications` → падают все три теста про идемпотентность; - отключить проверку подписи → `test_notification_with_invalid_token_is_rejected`; -- отключить проверку срока → `test_report_link_rejects_expired_token`. +- отключить проверку срока → `test_report_link_rejects_expired_token`; +- убрать `ON CONFLICT DO NOTHING` из INSERT в `payments` → + `test_parallel_checkout_does_not_create_second_payment` краснеет + _UniqueViolationError вместо 409; +- растянуть `_ABANDONED_AFTER_MINUTES` до бесконечности (= убрать границу по + времени) → `test_abandoned_checkout_does_not_lock_the_buyer_out` получает в + ответе мёртвую ссылку — красное ПО ЗНАЧЕНИЮ; +- убрать `_assert_estimate_access` из checkout → `test_checkout_rejects_foreign_estimate` + видит 200 и созданный платёж вместо 404. SQLite вместо этого не годится: NULLS NOT DISTINCT, jsonb, make_interval и CAST(:x AS uuid) там не существуют, а настоящий Postgres в юнит-тестах этого @@ -75,6 +83,26 @@ class _FakeDb: _NOTIFICATION_KEY = ("tbank_payment_id", "status", "amount_kopecks", "token") _ENTITLEMENT_KEY = ("payment_id", "kind", "ref_id") + # Частичный UNIQUE миграции 277: ключ (estimate_id, product_code), предикат — + # «живые» статусы. Переписан здесь ПО МИГРАЦИИ, а не импортирован из + # payments.py: иначе тест поехал бы вслед за дефектом. Совпадение списка с + # кодом отдельно гейтит test_live_status_predicate_matches_code. + _LIVE_PAYMENT_KEY = ("estimate_id", "product_code") + _LIVE_STATUSES = frozenset( + { + "NEW", + "FORM_SHOWED", + "PREAUTHORIZING", + "AUTHORIZING", + "AUTHORIZED", + "3DS_CHECKING", + "3DS_CHECKED", + "CHECKING", + "CHECKED", + "PROCESSING", + "CONFIRMING", + } + ) def __init__(self) -> None: self.notifications: list[SimpleNamespace] = [] @@ -87,8 +115,14 @@ class _FakeDb: amount_kopecks=_AMOUNT, estimate_id=_ESTIMATE_UUID, created_by=None, + # product_code=None — эта строка обслуживает тесты нотификаций и + # не должна попадать в ключ живого платежа checkout-тестов. + product_code=None, + payment_url=None, + created_at=datetime.now(tz=UTC), ) ] + self.estimate_created_by: str | None = None self.retain_until_updates: list[str] = [] self.commits = 0 @@ -118,15 +152,27 @@ class _FakeDb: None, ) ) + if "INSERT INTO payments" in sql: + return self._insert_payment(sql, params) if "FROM payments" in sql and sql.startswith("SELECT"): + if "estimate_id" in params: + return _Result(self._find_live_payment(params)) return _Result( next((p for p in self.payments if p.order_id == params.get("order_id")), None) ) + if "UPDATE payments" in sql and "DEADLINE_EXPIRED" in sql: + return _Result(self._expire_abandoned(params)) if "UPDATE payments" in sql: for payment in self.payments: if payment.order_id == params["order_id"] and "status" in params: payment.status = params["status"] + if "payment_url" in params: + payment.payment_url = params["payment_url"] return _Result(None) + if "FROM trade_in_estimates" in sql and sql.startswith("SELECT"): + if params.get("id") != _ESTIMATE_UUID: + return _Result(None) + return _Result(SimpleNamespace(id=_ESTIMATE_UUID, created_by=self.estimate_created_by)) if "UPDATE trade_in_estimates" in sql: self.retain_until_updates.append(params["id"]) return _Result(None) @@ -159,6 +205,58 @@ class _FakeDb: self.notifications.append(row) return _Result(row) + def _insert_payment(self, sql: str, params: dict[str, Any]) -> _Result: + """Ведёт себя как Postgres с частичным UNIQUE миграции 277. + + Конфликт наступает только когда УЖЕ есть строка с той же парой + (estimate_id, product_code) И статусом из предиката — ровно как у + частичного индекса. Без `ON CONFLICT DO NOTHING` в SQL — исключение. + """ + key = (params["estimate_id"], params["product_code"]) + if key[0] is not None and any( + self._key_of(row, self._LIVE_PAYMENT_KEY) == key and row.status in self._LIVE_STATUSES + for row in self.payments + ): + return self._conflict(sql) + row = SimpleNamespace( + id=f"pay-{len(self.payments) + 1}", + order_id=params["order_id"], + status="NEW", + amount_kopecks=params["amount"], + estimate_id=params["estimate_id"], + created_by=params["created_by"], + product_code=params["product_code"], + payment_url=None, + created_at=datetime.now(tz=UTC), + ) + self.payments.append(row) + return _Result(row) + + def _find_live_payment(self, params: dict[str, Any]) -> SimpleNamespace | None: + return next( + ( + row + for row in self.payments + if self._key_of(row, self._LIVE_PAYMENT_KEY) + == (params["estimate_id"], params["product_code"]) + and row.payment_url is not None + and row.status in set(params["reusable"]) + ), + None, + ) + + def _expire_abandoned(self, params: dict[str, Any]) -> None: + deadline = datetime.now(tz=UTC) - timedelta(minutes=int(params["mins"])) + for row in self.payments: + if ( + row.estimate_id == params["estimate_id"] + and row.product_code == params["product_code"] + and row.status in set(params["abandonable"]) + and row.created_at < deadline + ): + row.status = "DEADLINE_EXPIRED" + return None + def _insert_entitlement(self, sql: str, params: dict[str, Any]) -> _Result: key = (params["payment_id"], params["kind"], params["ref_id"]) if any(self._key_of(row, self._ENTITLEMENT_KEY) == key for row in self.entitlements): @@ -534,3 +632,169 @@ def test_price_matches_published_offer() -> None: _name, price_kopecks = _PRODUCTS["paid_report"] assert price_kopecks == int(match.group(1)) * 100 + + +def test_live_status_predicate_matches_code() -> None: + """Предикат частичного UNIQUE (277) и `_REUSABLE_STATUSES` — один список. + + Индекс шире кода запрещает легитимную повторную попытку оплаты; индекс уже + кода пропускает второй холд на карте. И то и другое — про деньги, поэтому + дрейф ловит тест, а не внимательность читателя. + """ + from app.api.v1.payments import _REUSABLE_STATUSES + + path = _REPO_ROOT / "backend" / "data" / "sql" / "277_payments_live_checkout_uidx.sql" + source = path.read_text(encoding="utf-8") + predicate = re.search(r"AND status IN \((.*?)\)", source, re.S) + assert predicate is not None, "предикат по статусам не найден в миграции 277" + + assert set(re.findall(r"'([^']+)'", predicate.group(1))) == set(_REUSABLE_STATUSES) + + +# ── checkout: один живой платёж на оценку ──────────────────────────────────── + + +@pytest.fixture() +def bank(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + """Подменяет Т-Банк: собирает вызовы Init и отдаёт предсказуемую ссылку. + + Список вызовов — не украшение: «второй Init» и есть «второй холд на карте», + поэтому проверяется именно его длина, а не только тело ответа. + """ + from app.api.v1 import payments as payments_module + + calls: list[dict[str, Any]] = [] + + class _FakeClient: + async def init_payment(self, **kwargs: Any) -> dict[str, Any]: + calls.append(kwargs) + return { + "Success": True, + "Status": "NEW", + "PaymentId": f"300000000{len(calls)}", + "PaymentURL": f"https://securepayments.tinkoff.ru/{len(calls)}", + } + + monkeypatch.setattr(payments_module, "_client", _FakeClient) + return calls + + +def _seed_live_payment(db: _FakeDb, *, payment_url: str | None, age: timedelta) -> SimpleNamespace: + row = SimpleNamespace( + id="pay-seed", + order_id="mera-seed", + status="NEW", + amount_kopecks=_AMOUNT, + estimate_id=_ESTIMATE_UUID, + created_by=None, + product_code="paid_report", + payment_url=payment_url, + created_at=datetime.now(tz=UTC) - age, + ) + db.payments.append(row) + return row + + +def _paid_report_rows(db: _FakeDb) -> list[SimpleNamespace]: + return [p for p in db.payments if p.product_code == "paid_report"] + + +def test_parallel_checkout_does_not_create_second_payment( + client: TestClient, db: _FakeDb, bank: list[dict[str, Any]] +) -> None: + """Двойной клик: соперник уже вставил строку, но ссылки от банка ещё нет. + + Второй запрос обязан отбиться о частичный UNIQUE (миграция 277) и не пойти + в банк — второй Init это второй холд на карте покупателя. Ответ 409, а не + выдуманная ссылка. + + Фальсификация (проверено): убрать `ON CONFLICT DO NOTHING` из INSERT в + payments → _FakeDb кидает _UniqueViolationError, как настоящий Postgres, и + тест краснеет вместо ответа 409; вернуть «SELECT, потом INSERT» без + обработки конфликта → вторая строка payments и второй вызов Init. + """ + _seed_live_payment(db, payment_url=None, age=timedelta(seconds=1)) + + response = client.post( + "/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID} + ) + + assert response.status_code == 409, response.text + assert len(_paid_report_rows(db)) == 1, "параллельный checkout создал второй платёж" + assert bank == [], "второй Init = второй холд на карте покупателя" + + +def test_repeat_checkout_reuses_live_link( + client: TestClient, db: _FakeDb, bank: list[dict[str, Any]] +) -> None: + """Честный повтор по живому платежу возвращает ТУ ЖЕ ссылку и не зовёт Init.""" + seeded = _seed_live_payment( + db, payment_url="https://securepayments/live", age=timedelta(minutes=5) + ) + + response = client.post( + "/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID} + ) + + assert response.status_code == 200, response.text + assert response.json()["payment_url"] == "https://securepayments/live" + assert response.json()["order_id"] == seeded.order_id + assert len(_paid_report_rows(db)) == 1 + assert bank == [] + + +def test_abandoned_checkout_does_not_lock_the_buyer_out( + client: TestClient, db: _FakeDb, bank: list[dict[str, Any]] +) -> None: + """Брошенный NEW старше окна не переиспользуется: ссылка у банка протухла. + + Без границы по времени покупатель навсегда получал бы одну и ту же мёртвую + ссылку и не мог начать оплату заново — нотификации по зависшему NEW может + не прийти вовсе, сам из этого статуса платёж не выйдет. + + Фальсификация (проверено руками): убрать UPDATE ... DEADLINE_EXPIRED (или + условие по created_at в нём) → в ответе старая мёртвая ссылка + https://securepayments/dead, Init не вызывается: тест краснеет по значению, + а не по исключению. + """ + stale = _seed_live_payment( + db, payment_url="https://securepayments/dead", age=timedelta(hours=2) + ) + + response = client.post( + "/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID} + ) + + assert response.status_code == 200, response.text + assert response.json()["payment_url"] == "https://securepayments.tinkoff.ru/1" + assert stale.status == "DEADLINE_EXPIRED" + assert len(bank) == 1, "новая попытка оплаты обязана получить свежую ссылку банка" + assert len(_paid_report_rows(db)) == 2 + + +def test_checkout_rejects_foreign_estimate( + client: TestClient, db: _FakeDb, bank: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch +) -> None: + """IDOR: checkout по чужой оценке — 404, платёж не создаётся. + + Дверь здесь не «показать чужой отчёт», а order_id: он же право доступа для + /payments/status/, который отдаёт capability-ссылку на отчёт, + как только владелец заплатит. + + Фальсификация (проверено руками): убрать вызов `_assert_estimate_access` в + checkout → 200 и строка в payments, тест краснеет по значению. + """ + from app.core import auth + + monkeypatch.setattr(auth, "get_role", lambda username: "pilot") + db.estimate_created_by = "victim" + + response = client.post( + "/api/v1/trade-in/payments/checkout", + json={"estimate_id": _ESTIMATE_UUID}, + headers={"X-Authenticated-User": "attacker"}, + ) + + assert response.status_code == 404, response.text + assert _paid_report_rows(db) == [] + assert bank == []