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
256 lines
13 KiB
Python
256 lines
13 KiB
Python
"""Тесты `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
|