gendesign/tradein-mvp/backend/tests/test_payments_token.py
bot-backend a32ccabd0d
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m3s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Deploy Trade-In / deploy (push) Successful in 1m24s
feat(tradein/payments): подпись Token, клиент Т-Банка и сборка чека (#2733)
PR-C платёжного контура: token.py (sign + verify_notification_token, оба эталонных вектора Т-Банка перепроверены независимо), receipt.py (54-ФЗ ФФД 1.05, целые копейки), tbank_client.py (Init/GetState/CheckOrder/Confirm/Cancel, таймаут 15с, 4xx не ретраится). Слой инертный: 0 импортёров, роутеров нет.
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-08-06 12:36:57 +00:00

198 lines
9.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты `app.services.payments.token` — подпись Token + проверка нотификаций.
Эталонные векторы (`test_sign_matches_official_init_vector`,
`test_sign_matches_official_notification_vector`) сняты ДОСЛОВНО живым
запросом (curl, 2026-08-06) с официального doc-портала Т-Банка:
- Init: https://developer.tbank.ru/eacq/intro/developer/token
(раздел «Сформировать токен»)
- Нотификация: https://developer.tbank.ru/eacq/intro/developer/notification
(раздел «Проверить токен уведомлений»)
Оба payload'а и оба итоговых hex-digest скопированы из HTML doc-портала
(не выдуманы) — см. промежуточные шаги в комментариях у каждого теста.
Если хеш перестанет сходиться — чинить `token.py`, НЕ тест.
"""
from __future__ import annotations
import hashlib
from app.services.payments.token import sign, verify_notification_token
# ── эталонный вектор №1: Init ────────────────────────────────────────────────
# Doc-портал, шаг за шагом (см. `token.py` docstring для полного описания):
# 1) [{"TerminalKey": "MerchantTerminalKey"}, {"Amount": 19200},
# {"OrderId": "00000"}, {"Description": "Подарочная карта на 1000 рублей"}]
# 2) + {"Password": "11111111111111"}
# 3) отсортировано по ключу: Amount, Description, OrderId, Password, TerminalKey
# 4) конкатенация значений:
# "19200Подарочная карта на 1000 рублей0000011111111111111MerchantTerminalKey"
# 5) SHA-256 → "72dd466f8ace0a37a1f740ce5fb78101712bc0665d91a8108c7c8a0ccd426db2"
_INIT_VECTOR_PAYLOAD = {
"TerminalKey": "MerchantTerminalKey",
"Amount": 19200,
"OrderId": "00000",
"Description": "Подарочная карта на 1000 рублей",
}
_INIT_VECTOR_PASSWORD = "11111111111111"
_INIT_VECTOR_TOKEN = "72dd466f8ace0a37a1f740ce5fb78101712bc0665d91a8108c7c8a0ccd426db2"
def test_sign_matches_official_init_vector() -> None:
"""Официальный вектор Init из документации Т-Банка."""
assert sign(_INIT_VECTOR_PAYLOAD, _INIT_VECTOR_PASSWORD) == _INIT_VECTOR_TOKEN
# ── эталонный вектор №2: нотификация ─────────────────────────────────────────
# Doc-портал, шаг за шагом:
# 1) [{"TerminalKey": "1234567890DEMO"}, {"OrderId": "000000"},
# {"Success": true}, {"Status": "AUTHORIZED"}, {"PaymentId": "0000000"},
# {"ErrorCode": "0"}, {"Amount": 1111}, {"CardId": "000000"},
# {"Pan": "200000******0000"}, {"ExpDate": "1111"}, {"RebillId": "000000"}]
# 2) + {"Password": "11111111111"}
# 3) отсортировано: Amount, CardId, ErrorCode, ExpDate, OrderId, Pan,
# Password, PaymentId, RebillId, Status, Success, TerminalKey
# 4) конкатенация значений:
# "111100000001111000000200000******0000111111111110000000000000AUTHORIZEDtrue1234567890DEMO"
# 5) SHA-256 → "1c0964277d0213349243065a0d5b838b8e90d2d25f740d0f2767836e710e80c8"
_NOTIFICATION_VECTOR_PAYLOAD = {
"TerminalKey": "1234567890DEMO",
"OrderId": "000000",
"Success": True,
"Status": "AUTHORIZED",
"PaymentId": "0000000",
"ErrorCode": "0",
"Amount": 1111,
"CardId": "000000",
"Pan": "200000******0000",
"ExpDate": "1111",
"RebillId": "000000",
}
_NOTIFICATION_VECTOR_PASSWORD = "11111111111"
_NOTIFICATION_VECTOR_TOKEN = "1c0964277d0213349243065a0d5b838b8e90d2d25f740d0f2767836e710e80c8"
def test_sign_matches_official_notification_vector() -> None:
"""Официальный вектор нотификации (Success/AUTHORIZED) из документации Т-Банка."""
assert (
sign(_NOTIFICATION_VECTOR_PAYLOAD, _NOTIFICATION_VECTOR_PASSWORD)
== _NOTIFICATION_VECTOR_TOKEN
)
def test_verify_notification_token_accepts_valid_official_vector() -> None:
"""`verify_notification_token` — тот же вектор, но с полем Token внутри payload."""
payload_with_token = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN}
assert verify_notification_token(payload_with_token, _NOTIFICATION_VECTOR_PASSWORD) is True
def test_verify_notification_token_rejects_tampered_field() -> None:
"""Изменили Amount после подписи → Token больше не совпадает → False."""
tampered = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN, "Amount": 9999}
assert verify_notification_token(tampered, _NOTIFICATION_VECTOR_PASSWORD) is False
def test_verify_notification_token_rejects_wrong_password() -> None:
payload_with_token = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": _NOTIFICATION_VECTOR_TOKEN}
assert verify_notification_token(payload_with_token, "wrong-password") is False
def test_verify_notification_token_rejects_missing_token() -> None:
assert verify_notification_token(dict(_NOTIFICATION_VECTOR_PAYLOAD), "any-password") is False
def test_verify_notification_token_rejects_empty_token() -> None:
payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": ""}
assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False
# ── unit-детали алгоритма ─────────────────────────────────────────────────────
def test_bool_true_becomes_lowercase_string() -> None:
"""`Success: True` (Python bool) → строка "true" в конкатенации."""
with_bool = sign({"A": True}, "pw")
with_string = sign({"A": "true"}, "pw")
assert with_bool == with_string
def test_bool_false_becomes_lowercase_string() -> None:
with_bool = sign({"A": False}, "pw")
with_string = sign({"A": "false"}, "pw")
assert with_bool == with_string
def test_int_amount_stringified_without_quotes_semantics() -> None:
"""`Amount: 1111` (int) даёт тот же результат, что и `Amount: "1111"` (str)."""
with_int = sign({"Amount": 1111}, "pw")
with_str = sign({"Amount": "1111"}, "pw")
assert with_int == with_str
def test_float_without_leading_zero_loss_and_no_exponent() -> None:
"""Дробное число сериализуется без экспоненты и без хвостовых нулей.
Ключи после добавления Password: "A" < "Password" (лексикографически),
поэтому конкатенация — значение A, затем значение Password.
"""
raw = "1234.5" + "pw"
expected = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert sign({"A": 1234.5}, "pw") == expected
def test_large_float_has_no_exponential_notation() -> None:
"""Очень большое число не сваливается в экспоненциальную запись (`1e+21`)."""
raw = "1000000000000000000000" + "pw"
expected = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert sign({"A": 1e21}, "pw") == expected
def test_none_values_are_skipped() -> None:
"""`None`-поля не участвуют в конкатенации вообще (не как пустая строка)."""
with_none = sign({"A": "x", "B": None}, "pw")
without_key = sign({"A": "x"}, "pw")
assert with_none == without_key
def test_nested_receipt_dict_is_ignored() -> None:
"""Вложенный `Receipt` (dict) не участвует в подписи."""
without_receipt = sign({"A": "x"}, "pw")
with_receipt = sign({"A": "x", "Receipt": {"Email": "a@test.ru", "Items": []}}, "pw")
assert without_receipt == with_receipt
def test_nested_data_dict_is_ignored() -> None:
"""Вложенный `DATA`/`Data` (dict) не участвует в подписи — оба варианта регистра ключа."""
baseline = sign({"A": "x"}, "pw")
assert sign({"A": "x", "DATA": {"Phone": "+70000000000"}}, "pw") == baseline
assert sign({"A": "x", "Data": {"Phone": "+70000000000"}}, "pw") == baseline
def test_nested_list_items_is_ignored() -> None:
"""Вложенный список (`Items`/`Shops` как root-ключ) не участвует в подписи."""
baseline = sign({"A": "x"}, "pw")
assert sign({"A": "x", "Items": [{"Name": "тест"}]}, "pw") == baseline
assert sign({"A": "x", "Shops": [{"ShopCode": "1"}]}, "pw") == baseline
def test_existing_token_field_in_payload_is_excluded() -> None:
"""Если в payload уже есть `Token` (например, переподписываем нотификацию) — игнорируется."""
without_token = sign({"A": "x"}, "pw")
with_token = sign({"A": "x", "Token": "stale-value-from-previous-signing"}, "pw")
assert without_token == with_token
def test_sort_is_by_key_name_not_insertion_order() -> None:
"""Порядок вставки ключей в payload не влияет на результат — сортировка по ключу."""
forward = sign({"Zeta": "1", "Alpha": "2", "Mid": "3"}, "pw")
reversed_order = sign({"Mid": "3", "Alpha": "2", "Zeta": "1"}, "pw")
assert forward == reversed_order
# sanity: строка действительно собрана в алфавитном порядке ключей.
# Ключи с Password: Alpha < Mid < Password < Zeta (лексикографически).
raw = "".join(["2", "3", "pw", "1"]) # Alpha->2, Mid->3, Password->pw, Zeta->1
expected = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert forward == expected