"""Тесты `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