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
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:
parent
42a1247f2b
commit
cd0522ceae
3 changed files with 455 additions and 21 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue