fix(payments): один живой платёж на оценку — гарантия БД, а не порядка выполнения
Some checks failed
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Failing after 14s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m27s

Идемпотентность 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/<order_id>, отдающего capability-ссылку на
  отчёт. Проверка ставится только для оценок с владельцем: у анонимной покупки
  идентичности нет, правом там работает сам estimate_id.

Тесты двусторонние, фальсификация прогнана: снятие ON CONFLICT / границы по
времени / IDOR-гварда красит ровно один тест каждый раз, два из трёх — по
значению ответа.
This commit is contained in:
bot-backend 2026-08-29 19:11:48 +05:00
parent 42a1247f2b
commit cd0522ceae
3 changed files with 455 additions and 21 deletions

View file

@ -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/<order_id>, который отдаёт 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:

View file

@ -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;

View file

@ -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/<order_id>, который отдаёт 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 == []