"""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)