fix(payments): один живой платёж на оценку — гарантия БД, а не порядка выполнения
Идемпотентность 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
28e13d5841
commit
c5315539fa
3 changed files with 455 additions and 21 deletions
|
|
@ -12,11 +12,13 @@ Merge безопасен на выключенном контуре — на п
|
||||||
терминала (`app/main.py` роняет старт, если включить без ключей).
|
терминала (`app/main.py` роняет старт, если включить без ключей).
|
||||||
|
|
||||||
── Идемпотентность держится на БД, а не на «проверить-потом-вставить» ────────
|
── Идемпотентность держится на БД, а не на «проверить-потом-вставить» ────────
|
||||||
Обе гонки, которые здесь реальны (банк шлёт AUTHORIZED и CONFIRMED
|
Все три гонки, которые здесь реальны (двойной клик по кнопке оплаты; банк шлёт
|
||||||
одновременно; банк ретраит нотификацию почасово сутки), закрыты UNIQUE-ключами
|
AUTHORIZED и CONFIRMED одновременно; банк ретраит нотификацию почасово сутки),
|
||||||
миграции 233 + `ON CONFLICT DO NOTHING`. Пара «SELECT, потом INSERT» здесь была
|
закрыты UNIQUE-ключами миграций 233 и 277 + `ON CONFLICT DO NOTHING`. Пара
|
||||||
бы дефектом: между ними успевает пройти параллельный запрос, и выдача
|
«SELECT, потом INSERT» здесь была бы дефектом: между ними успевает пройти
|
||||||
происходит дважды.
|
параллельный запрос, и выдача (или холд на карте) происходит дважды. SELECT
|
||||||
|
живого платежа в `checkout` остался, но только как быстрый путь для честного
|
||||||
|
повтора — гонку ловит не он, а `payments_live_estimate_product_uidx`.
|
||||||
|
|
||||||
`payment_notifications.processed_at` — единственный признак «выдача
|
`payment_notifications.processed_at` — единственный признак «выдача
|
||||||
состоялась». Наличие строки нотификации таким признаком НЕ является: процесс
|
состоялась». Наличие строки нотификации таким признаком НЕ является: процесс
|
||||||
|
|
@ -59,7 +61,11 @@ from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
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.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.schemas.trade_in import AggregatedEstimate
|
from app.schemas.trade_in import AggregatedEstimate
|
||||||
|
|
@ -149,8 +155,38 @@ _PRE_CONFIRM_STATUSES = frozenset(
|
||||||
# оценке обязан вернуть ту же ссылку, а не создавать второй холд на карте
|
# оценке обязан вернуть ту же ссылку, а не создавать второй холд на карте
|
||||||
# покупателя. Терминальные (CANCELED/REJECTED/REFUNDED/...) сюда не входят —
|
# покупателя. Терминальные (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
|
_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-"
|
_ORDER_ID_PREFIX = "mera-"
|
||||||
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
|
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
|
||||||
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
|
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
|
||||||
|
|
@ -209,9 +245,15 @@ def checkout(
|
||||||
) -> CheckoutOut:
|
) -> CheckoutOut:
|
||||||
"""Создаёт платёж и возвращает `PaymentURL` формы Т-Банка.
|
"""Создаёт платёж и возвращает `PaymentURL` формы Т-Банка.
|
||||||
|
|
||||||
Идемпотентность по своему `order_id`: живой платёж по той же паре
|
Идемпотентность — свойство БД, а не порядка выполнения: частичный UNIQUE
|
||||||
(estimate_id, product_code) переиспользуется вместе с уже полученным
|
(estimate_id, product_code) по живым статусам (миграция 277) физически не
|
||||||
`PaymentURL` — второй `Init` создал бы второй холд на карте покупателя.
|
даёт существовать двум живым платежам по одной оценке, а `ON CONFLICT DO
|
||||||
|
NOTHING` превращает проигрыш в гонке в ответ, а не во второй `Init` (и,
|
||||||
|
значит, во второй холд на карте покупателя).
|
||||||
|
|
||||||
|
Три исхода: живой платёж с готовой ссылкой — 200 с ТОЙ ЖЕ ссылкой; параллельный
|
||||||
|
checkout ещё не дошёл до ответа банка — 409 (ретрай через секунду вернёт
|
||||||
|
ссылку); иначе создаём новый платёж.
|
||||||
"""
|
"""
|
||||||
_require_enabled()
|
_require_enabled()
|
||||||
|
|
||||||
|
|
@ -232,26 +274,45 @@ def checkout(
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if estimate is None:
|
if estimate is None:
|
||||||
raise HTTPException(status_code=404, detail="estimate not found or expired")
|
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(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT order_id, payment_url, amount_kopecks, status
|
UPDATE payments
|
||||||
FROM payments
|
SET status = 'DEADLINE_EXPIRED', updated_at = NOW()
|
||||||
WHERE estimate_id = CAST(:estimate_id AS uuid)
|
WHERE estimate_id = CAST(:estimate_id AS uuid)
|
||||||
AND product_code = :product_code
|
AND product_code = :product_code
|
||||||
AND payment_url IS NOT NULL
|
AND status = ANY(CAST(:abandonable AS text[]))
|
||||||
AND status = ANY(CAST(:reusable AS text[]))
|
AND created_at < NOW() - make_interval(mins => CAST(:mins AS int))
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"estimate_id": str(payload.estimate_id),
|
"estimate_id": str(payload.estimate_id),
|
||||||
"product_code": payload.product_code,
|
"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:
|
if existing is not None:
|
||||||
return CheckoutOut(
|
return CheckoutOut(
|
||||||
order_id=existing.order_id,
|
order_id=existing.order_id,
|
||||||
|
|
@ -266,7 +327,7 @@ def checkout(
|
||||||
# нотификация найдёт платёж, а UNIQUE(order_id) — страховка от двойной
|
# нотификация найдёт платёж, а UNIQUE(order_id) — страховка от двойной
|
||||||
# записи. 5 + 32 = 37 символов, влезает в CHECK(char_length <= 50).
|
# записи. 5 + 32 = 37 символов, влезает в CHECK(char_length <= 50).
|
||||||
order_id = f"{_ORDER_ID_PREFIX}{uuid4().hex}"
|
order_id = f"{_ORDER_ID_PREFIX}{uuid4().hex}"
|
||||||
db.execute(
|
inserted = db.execute( # fetchone() ДО commit(): курсор после коммита пуст
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO payments (
|
INSERT INTO payments (
|
||||||
|
|
@ -276,6 +337,8 @@ def checkout(
|
||||||
:order_id, :terminal_key, :product_code, :amount, 'NEW',
|
:order_id, :terminal_key, :product_code, :amount, 'NEW',
|
||||||
:created_by, CAST(:estimate_id AS uuid), :email, :phone
|
: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,
|
"email": payload.customer_email,
|
||||||
"phone": payload.customer_phone,
|
"phone": payload.customer_phone,
|
||||||
},
|
},
|
||||||
)
|
).fetchone()
|
||||||
db.commit()
|
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:
|
try:
|
||||||
# asyncio.run в синхронном хендлере — тот же мост, что и
|
# asyncio.run в синхронном хендлере — тот же мост, что и
|
||||||
# trade_in._try_revive_dead_estimate: Starlette гоняет `def`-хендлер в
|
# 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(
|
def _build_receipt_or_none(
|
||||||
item_name: str, amount_kopecks: int, payload: CheckoutInput
|
item_name: str, amount_kopecks: int, payload: CheckoutInput
|
||||||
) -> dict[str, Any] | None:
|
) -> 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` → падают все три теста
|
- убрать его же из INSERT в `payment_notifications` → падают все три теста
|
||||||
про идемпотентность;
|
про идемпотентность;
|
||||||
- отключить проверку подписи → `test_notification_with_invalid_token_is_rejected`;
|
- отключить проверку подписи → `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 и
|
SQLite вместо этого не годится: NULLS NOT DISTINCT, jsonb, make_interval и
|
||||||
CAST(:x AS uuid) там не существуют, а настоящий Postgres в юнит-тестах этого
|
CAST(:x AS uuid) там не существуют, а настоящий Postgres в юнит-тестах этого
|
||||||
|
|
@ -75,6 +83,26 @@ class _FakeDb:
|
||||||
|
|
||||||
_NOTIFICATION_KEY = ("tbank_payment_id", "status", "amount_kopecks", "token")
|
_NOTIFICATION_KEY = ("tbank_payment_id", "status", "amount_kopecks", "token")
|
||||||
_ENTITLEMENT_KEY = ("payment_id", "kind", "ref_id")
|
_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:
|
def __init__(self) -> None:
|
||||||
self.notifications: list[SimpleNamespace] = []
|
self.notifications: list[SimpleNamespace] = []
|
||||||
|
|
@ -87,8 +115,14 @@ class _FakeDb:
|
||||||
amount_kopecks=_AMOUNT,
|
amount_kopecks=_AMOUNT,
|
||||||
estimate_id=_ESTIMATE_UUID,
|
estimate_id=_ESTIMATE_UUID,
|
||||||
created_by=None,
|
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.retain_until_updates: list[str] = []
|
||||||
self.commits = 0
|
self.commits = 0
|
||||||
|
|
||||||
|
|
@ -118,15 +152,27 @@ class _FakeDb:
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if "INSERT INTO payments" in sql:
|
||||||
|
return self._insert_payment(sql, params)
|
||||||
if "FROM payments" in sql and sql.startswith("SELECT"):
|
if "FROM payments" in sql and sql.startswith("SELECT"):
|
||||||
|
if "estimate_id" in params:
|
||||||
|
return _Result(self._find_live_payment(params))
|
||||||
return _Result(
|
return _Result(
|
||||||
next((p for p in self.payments if p.order_id == params.get("order_id")), None)
|
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:
|
if "UPDATE payments" in sql:
|
||||||
for payment in self.payments:
|
for payment in self.payments:
|
||||||
if payment.order_id == params["order_id"] and "status" in params:
|
if payment.order_id == params["order_id"] and "status" in params:
|
||||||
payment.status = params["status"]
|
payment.status = params["status"]
|
||||||
|
if "payment_url" in params:
|
||||||
|
payment.payment_url = params["payment_url"]
|
||||||
return _Result(None)
|
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:
|
if "UPDATE trade_in_estimates" in sql:
|
||||||
self.retain_until_updates.append(params["id"])
|
self.retain_until_updates.append(params["id"])
|
||||||
return _Result(None)
|
return _Result(None)
|
||||||
|
|
@ -159,6 +205,58 @@ class _FakeDb:
|
||||||
self.notifications.append(row)
|
self.notifications.append(row)
|
||||||
return _Result(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:
|
def _insert_entitlement(self, sql: str, params: dict[str, Any]) -> _Result:
|
||||||
key = (params["payment_id"], params["kind"], params["ref_id"])
|
key = (params["payment_id"], params["kind"], params["ref_id"])
|
||||||
if any(self._key_of(row, self._ENTITLEMENT_KEY) == key for row in self.entitlements):
|
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"]
|
_name, price_kopecks = _PRODUCTS["paid_report"]
|
||||||
assert price_kopecks == int(match.group(1)) * 100
|
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