198 lines
6.3 KiB
Python
198 lines
6.3 KiB
Python
"""Unit tests for core.auth: JWT signing/verification and Telegram identity checks.
|
|
|
|
These tests do not touch the database; they exercise only the crypto helpers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import hashlib
|
|
import hmac
|
|
import uuid
|
|
from urllib.parse import urlencode
|
|
|
|
import pytest
|
|
|
|
from contract_check.core.auth import (
|
|
AuthError,
|
|
TokenExpiredError,
|
|
TokenInvalidError,
|
|
create_access_token,
|
|
verify_access_token,
|
|
verify_bot_identity,
|
|
verify_telegram_miniapp_init_data,
|
|
verify_telegram_web_payload,
|
|
)
|
|
from contract_check.core.config import get_settings
|
|
|
|
_BOT_TOKEN = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi"
|
|
_SECRET_KEY = hmac.new(
|
|
_BOT_TOKEN.encode("utf-8"),
|
|
b"WebAppData",
|
|
hashlib.sha256,
|
|
).digest()
|
|
|
|
|
|
def _make_web_payload(telegram_id: int, auth_date: int | None = None) -> dict[str, object]:
|
|
if auth_date is None:
|
|
auth_date = int(dt.datetime.now(tz=dt.UTC).timestamp())
|
|
data = {
|
|
"id": telegram_id,
|
|
"first_name": "Test",
|
|
"auth_date": auth_date,
|
|
}
|
|
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(data.items()))
|
|
data["hash"] = hmac.new(
|
|
_SECRET_KEY,
|
|
data_check_string.encode("utf-8"),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
return data
|
|
|
|
|
|
def _make_init_data(telegram_id: int, auth_date: int | None = None) -> str:
|
|
if auth_date is None:
|
|
auth_date = int(dt.datetime.now(tz=dt.UTC).timestamp())
|
|
user_json = f'{{"id":{telegram_id},"first_name":"Test"}}'
|
|
params = {"user": user_json, "auth_date": str(auth_date), "chat_type": "private"}
|
|
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(params.items()))
|
|
params["hash"] = hmac.new(
|
|
_SECRET_KEY,
|
|
data_check_string.encode("utf-8"),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
return urlencode(params)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_settings_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
get_settings.cache_clear()
|
|
monkeypatch.setenv("JWT_SECRET", "unit-test-secret-do-not-use-in-prod")
|
|
monkeypatch.setenv("JWT_ALGORITHM", "HS256")
|
|
monkeypatch.setenv("JWT_ACCESS_TTL_MINUTES", "1440")
|
|
get_settings.cache_clear()
|
|
yield
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_create_and_verify_access_token() -> None:
|
|
user_id = uuid.uuid4()
|
|
telegram_id = 42
|
|
token = create_access_token(user_id, telegram_id)
|
|
claims = verify_access_token(token)
|
|
assert claims.sub == user_id
|
|
assert claims.telegram_id == telegram_id
|
|
assert claims.type == "access"
|
|
|
|
|
|
def test_verify_token_rejects_tampered_signature() -> None:
|
|
token = create_access_token(uuid.uuid4(), 42)
|
|
# Flip a bit in the middle of the payload; this breaks the signature reliably.
|
|
tampered = token[:-10] + ("A" if token[-10] != "A" else "B") + token[-9:]
|
|
with pytest.raises(TokenInvalidError):
|
|
verify_access_token(tampered)
|
|
|
|
|
|
def test_verify_token_rejects_expired_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("JWT_ACCESS_TTL_MINUTES", "-1")
|
|
get_settings.cache_clear()
|
|
token = create_access_token(uuid.uuid4(), 42)
|
|
with pytest.raises(TokenExpiredError):
|
|
verify_access_token(token)
|
|
|
|
|
|
def test_verify_token_rejects_wrong_audience() -> None:
|
|
import jwt
|
|
|
|
user_id = uuid.uuid4()
|
|
token = jwt.encode(
|
|
{
|
|
"sub": str(user_id),
|
|
"telegram_id": 42,
|
|
"type": "access",
|
|
"exp": int((dt.datetime.now(tz=dt.UTC) + dt.timedelta(hours=1)).timestamp()),
|
|
"iat": int(dt.datetime.now(tz=dt.UTC).timestamp()),
|
|
"aud": "wrong-audience",
|
|
},
|
|
key=get_settings().jwt_secret,
|
|
algorithm="HS256",
|
|
)
|
|
with pytest.raises(TokenInvalidError):
|
|
verify_access_token(token)
|
|
|
|
|
|
def test_verify_token_rejects_missing_telegram_id() -> None:
|
|
import jwt
|
|
|
|
user_id = uuid.uuid4()
|
|
token = jwt.encode(
|
|
{
|
|
"sub": str(user_id),
|
|
"type": "access",
|
|
"exp": int((dt.datetime.now(tz=dt.UTC) + dt.timedelta(hours=1)).timestamp()),
|
|
"iat": int(dt.datetime.now(tz=dt.UTC).timestamp()),
|
|
"aud": "contract-check",
|
|
},
|
|
key=get_settings().jwt_secret,
|
|
algorithm="HS256",
|
|
)
|
|
with pytest.raises(TokenInvalidError):
|
|
verify_access_token(token)
|
|
|
|
|
|
def test_verify_telegram_web_payload_valid() -> None:
|
|
payload = _make_web_payload(12345)
|
|
identity = verify_telegram_web_payload(payload, _BOT_TOKEN)
|
|
assert identity.telegram_id == 12345
|
|
|
|
|
|
def test_verify_telegram_web_payload_bad_hash() -> None:
|
|
payload = _make_web_payload(12345)
|
|
payload["hash"] = "0" * 64
|
|
with pytest.raises(AuthError, match="signature mismatch"):
|
|
verify_telegram_web_payload(payload, _BOT_TOKEN)
|
|
|
|
|
|
def test_verify_telegram_web_payload_expired() -> None:
|
|
old_auth_date = int(dt.datetime.now(tz=dt.UTC).timestamp()) - 25 * 60 * 60
|
|
payload = _make_web_payload(12345, auth_date=old_auth_date)
|
|
with pytest.raises(AuthError, match="expired"):
|
|
verify_telegram_web_payload(payload, _BOT_TOKEN)
|
|
|
|
|
|
def test_verify_telegram_web_payload_missing_bot_token() -> None:
|
|
with pytest.raises(AuthError, match="not configured"):
|
|
verify_telegram_web_payload(_make_web_payload(1), "")
|
|
|
|
|
|
def test_verify_telegram_miniapp_init_data_valid() -> None:
|
|
init_data = _make_init_data(67890)
|
|
identity = verify_telegram_miniapp_init_data(init_data, _BOT_TOKEN)
|
|
assert identity.telegram_id == 67890
|
|
|
|
|
|
def test_verify_telegram_miniapp_init_data_bad_hash() -> None:
|
|
init_data = _make_init_data(67890)
|
|
init_data = init_data[:-64] + "0" * 64
|
|
with pytest.raises(AuthError, match="signature mismatch"):
|
|
verify_telegram_miniapp_init_data(init_data, _BOT_TOKEN)
|
|
|
|
|
|
def test_verify_telegram_miniapp_init_data_missing_user() -> None:
|
|
# Sign an empty data-check-string; hash is valid, but there is no user param.
|
|
empty_hash = hmac.new(_SECRET_KEY, b"", hashlib.sha256).hexdigest()
|
|
bad = f"hash={empty_hash}"
|
|
with pytest.raises(AuthError, match="missing user"):
|
|
verify_telegram_miniapp_init_data(bad, _BOT_TOKEN)
|
|
|
|
|
|
def test_verify_bot_identity_valid() -> None:
|
|
identity = verify_bot_identity(111222)
|
|
assert identity.telegram_id == 111222
|
|
|
|
|
|
def test_verify_bot_identity_invalid() -> None:
|
|
with pytest.raises(AuthError):
|
|
verify_bot_identity(-1)
|
|
with pytest.raises(AuthError):
|
|
verify_bot_identity(0)
|