gendesign/tradein-mvp/backend/tests/test_payments_token.py
bot-backend 00d1f78668
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
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 3m10s
fix(tradein/payments): строгий разбор нотификации и отказ вместо догадок на враждебном входе
2026-08-06 15:48:57 +03:00

256 lines
13 KiB
Python
Raw 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
import pytest
from app.services.payments.token import TokenSigningError, 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_field_raises_instead_of_guessing_format() -> None:
"""`float` больше не сериализуется по угадываемому формату — явный отказ.
Раньше `format(value, "f")` + rstrip нулей выдавал для `0.1 + 0.2` строку
"0.3", а `json.dumps(0.1 + 0.2)` реально даёт "0.30000000000000004"
подписывалось не то, что уходит в JSON-теле запроса. Для денежного пути
(Amount — всегда int, копейки) правильнее упасть, чем угадать формат.
"""
with pytest.raises(TokenSigningError):
sign({"A": 1234.5}, "pw")
def test_large_float_field_also_raises() -> None:
"""Тот же явный отказ и для значений, которые раньше ушли бы без экспоненты."""
with pytest.raises(TokenSigningError):
sign({"A": 1e21}, "pw")
def test_amount_plus_float_sum_would_have_diverged_from_json_raises() -> None:
"""Закрепляет мотивацию отказа: 0.1+0.2 != json.dumps(0.1+0.2) как строка."""
computed = 0.1 + 0.2
assert format(computed, "f").rstrip("0").rstrip(".") == "0.3"
with pytest.raises(TokenSigningError):
sign({"Amount": computed}, "pw")
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
# ── verify_notification_token: НИКОГДА не кидает исключение на мусоре ─────────
#
# После появления публичной ручки нотификации (PR-D) необработанное
# исключение здесь = неаутентифицированный HTTP 500 в ответ банку, а любой
# ответ, отличный от "OK", банк трактует как временный сбой и ретраит
# нотификацию почасово в течение суток — см. docstring
# `verify_notification_token`.
def test_verify_notification_token_rejects_non_dict_payload_list() -> None:
"""payload — список, не dict → `.get()` кинул бы AttributeError без guard'а."""
assert verify_notification_token([{"Token": "x"}], "pw") is False # type: ignore[arg-type]
def test_verify_notification_token_rejects_non_dict_payload_string() -> None:
assert verify_notification_token("not-a-dict", "pw") is False # type: ignore[arg-type]
def test_verify_notification_token_rejects_non_dict_payload_none() -> None:
assert verify_notification_token(None, "pw") is False # type: ignore[arg-type]
def test_verify_notification_token_rejects_non_ascii_token() -> None:
"""Не-ASCII Token → `hmac.compare_digest` кинул бы TypeError без guard'а."""
payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": "кириллица-не-hex-токен"}
assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False
def test_verify_notification_token_rejects_non_string_token() -> None:
payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": 12345}
assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False
def test_verify_notification_token_does_not_raise_on_float_field() -> None:
"""Поле-float где-то в payload (после отказа sign() от float) → False, не исключение."""
payload = {
"TerminalKey": "demo",
"OrderId": "1",
"Amount": 11.5, # float — sign() теперь явно падает на нём
"Token": "0" * 64,
}
assert verify_notification_token(payload, "pw") is False
def test_verify_notification_token_rejects_empty_dict() -> None:
assert verify_notification_token({}, "pw") is False