feat(e2e): shared initData / Login Widget payload builder for E2E tests

- Add TelegramAuthPayloadBuilder in GmRelay.Shared for C# tests.
- Refactor TelegramAuthServiceTests to use the shared builder.
- Add Python equivalent (telegram_init_data.py) for E2E runner.
- Add self-contained Python tests and E2E README.

Closes #144

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 11:53:08 +03:00
parent 6a59c48348
commit 5319592964
7 changed files with 893 additions and 124 deletions
+18
View File
@@ -0,0 +1,18 @@
# Python cache and virtual environments
__pycache__/
*.pyc
*.pyo
*.pyd
.env
.venv/
venv/
# Playwright artifacts
screenshots/
test-results/
playwright-report/
# E2E runtime state
*.session
*.session-journal
session-*.json
+71
View File
@@ -0,0 +1,71 @@
# GmRelay E2E Tests
This directory contains end-to-end tests that run **locally** against real Telegram infrastructure and the GmRelay Web dashboard. They are intentionally **not part of CI** because they require:
- a real Telegram test user account;
- `api_id` / `api_hash` from https://my.telegram.org;
- a pre-authenticated MTProto session;
- a running PostgreSQL, GmRelay.Bot, and GmRelay.Web.
## Structure
```text
tests/e2e/
├── README.md # this file
├── helpers/
│ ├── telegram_init_data.py # generate valid Telegram initData / Login Widget payloads
│ └── test_telegram_init_data.py # unit tests for the helper
└── ... (runner and scenarios will land here)
```
## `telegram_init_data.py`
A small Python helper that produces Telegram Mini App `initData` and Login Widget payloads with valid HMAC-SHA256 signatures. It mirrors `GmRelay.Shared.Telegram.TelegramAuthPayloadBuilder`.
Use it to open the Blazor/Mini App dashboard in Playwright without logging into Telegram:
```python
from helpers.telegram_init_data import build_mini_app_init_data
init = build_mini_app_init_data(
bot_token="YOUR_BOT_TOKEN",
telegram_id=424242,
first_name="Test",
username="tester")
await page.goto(f"https://localhost:8080/#tgWebAppData={init.init_data_raw}")
```
## C# equivalent
For tests inside the solution, use `GmRelay.Shared.Telegram.TelegramAuthPayloadBuilder`:
```csharp
var init = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken: "YOUR_BOT_TOKEN",
telegramId: 424242L,
firstName: "Test",
username: "tester");
// init.InitDataRaw is a valid initData string.
```
## Running the helper tests
```bash
cd tests/e2e/helpers
python -m pytest test_telegram_init_data.py -v
```
## Roadmap
See the Gitea milestone **[Этап — E2E-тестирование Telegram + Web](https://git.codeanddice.ru/Toutsu/GmRelayBot/milestone/13)** and its issues:
- [#144](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/144) initData helper ✅ (this directory)
- [#145](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/145) Playwright dashboard tests
- [#146](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/146) MTProto test user client
- [#147](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/147) Group creation automation
- [#148](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/148) `/newsession` scenario
- [#149](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/149) join/leave/waitlist/reschedule scenarios
- [#150](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/150) Web dashboard round-trip verification
- [#151](https://git.codeanddice.ru/Toutsu/GmRelayBot/issues/151) Console runner + cleanup
+191
View File
@@ -0,0 +1,191 @@
"""
Generate Telegram Mini App initData and Login Widget payloads for local E2E tests.
This mirrors GmRelay.Shared.Telegram.TelegramAuthPayloadBuilder so the Python E2E
runner can produce authentication payloads that pass GmRelay.Web.Services.TelegramAuthService
validation without talking to real Telegram servers.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
import urllib.parse
from dataclasses import dataclass
from typing import Optional
def _login_widget_secret_key(bot_token: str) -> bytes:
return hashlib.sha256(bot_token.encode("utf-8")).digest()
def _mini_app_secret_key(bot_token: str) -> bytes:
return hmac.new(
key=b"WebAppData",
msg=bot_token.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
def compute_login_widget_hash(bot_token: str, values: dict[str, str]) -> str:
"""Compute HMAC-SHA256 hash used by Telegram Login Widget callbacks."""
data_check_string = "\n".join(
f"{k}={values[k]}" for k in sorted(values.keys()) if k != "hash"
)
secret_key = _login_widget_secret_key(bot_token)
return hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
def compute_mini_app_hash(bot_token: str, values: dict[str, str]) -> str:
"""Compute HMAC-SHA256 hash used by Telegram Mini App initData."""
data_check_string = "\n".join(
f"{k}={values[k]}" for k in sorted(values.keys()) if k != "hash"
)
secret_key = _mini_app_secret_key(bot_token)
return hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
@dataclass(frozen=True)
class LoginWidgetResult:
telegram_id: int
first_name: str
last_name: Optional[str]
username: Optional[str]
photo_url: Optional[str]
auth_date: int
hash: str
query_string: str
def build_login_widget(
bot_token: str,
telegram_id: int,
first_name: str,
last_name: Optional[str] = None,
username: Optional[str] = None,
photo_url: Optional[str] = None,
auth_date: Optional[int] = None,
) -> LoginWidgetResult:
"""Build a Telegram Login Widget query string and hash."""
timestamp = auth_date if auth_date is not None else int(time.time())
values: dict[str, str] = {
"auth_date": str(timestamp),
"first_name": first_name,
"id": str(telegram_id),
}
if last_name:
values["last_name"] = last_name
if photo_url:
values["photo_url"] = photo_url
if username:
values["username"] = username
hash_value = compute_login_widget_hash(bot_token, values)
values["hash"] = hash_value
query_string = "&".join(
f"{urllib.parse.quote(k)}={urllib.parse.quote(v)}" for k, v in values.items()
)
return LoginWidgetResult(
telegram_id=telegram_id,
first_name=first_name,
last_name=last_name,
username=username,
photo_url=photo_url,
auth_date=timestamp,
hash=hash_value,
query_string=query_string,
)
@dataclass(frozen=True)
class MiniAppInitDataResult:
telegram_id: int
first_name: str
last_name: Optional[str]
username: Optional[str]
photo_url: Optional[str]
auth_date: int
hash: str
init_data_raw: str
def build_mini_app_init_data(
bot_token: str,
telegram_id: int,
first_name: str,
last_name: Optional[str] = None,
username: Optional[str] = None,
photo_url: Optional[str] = None,
language_code: Optional[str] = None,
is_premium: bool = False,
chat_id: Optional[int] = None,
chat_type: Optional[str] = None,
chat_title: Optional[str] = None,
query_id: Optional[str] = None,
start_param: Optional[str] = None,
auth_date: Optional[int] = None,
) -> MiniAppInitDataResult:
"""Build a Telegram Mini App initData raw string."""
user_payload: dict[str, object] = {
"id": telegram_id,
"first_name": first_name,
}
if last_name is not None:
user_payload["last_name"] = last_name
if username is not None:
user_payload["username"] = username
if photo_url is not None:
user_payload["photo_url"] = photo_url
if language_code is not None:
user_payload["language_code"] = language_code
if is_premium:
user_payload["is_premium"] = True
user_json = json.dumps(user_payload, separators=(",", ":"))
timestamp = auth_date if auth_date is not None else int(time.time())
values: dict[str, str] = {
"auth_date": str(timestamp),
"user": user_json,
}
if query_id:
values["query_id"] = query_id
if start_param:
values["start_param"] = start_param
if chat_id is not None:
chat_payload: dict[str, object] = {"id": chat_id, "type": chat_type or "private"}
if chat_title is not None:
chat_payload["title"] = chat_title
values["chat"] = json.dumps(chat_payload, separators=(",", ":"))
hash_value = compute_mini_app_hash(bot_token, values)
pairs = [
f"{urllib.parse.quote(k)}={urllib.parse.quote(v)}" for k, v in values.items()
]
pairs.append(f"hash={hash_value}")
init_data_raw = "&".join(pairs)
return MiniAppInitDataResult(
telegram_id=telegram_id,
first_name=first_name,
last_name=last_name,
username=username,
photo_url=photo_url,
auth_date=timestamp,
hash=hash_value,
init_data_raw=init_data_raw,
)
@@ -0,0 +1,133 @@
"""Self-contained tests for telegram_init_data helper.
Run with: python tests/e2e/helpers/test_telegram_init_data.py
"""
import sys
import urllib.parse
from telegram_init_data import (
build_login_widget,
build_mini_app_init_data,
compute_login_widget_hash,
compute_mini_app_hash,
)
def _parse_init_data(init_data_raw: str) -> dict[str, str]:
return {
k: v for k, v in (pair.split("=", 1) for pair in init_data_raw.split("&"))
}
def test_login_widget_hash_matches_expected_algorithm():
bot_token = "test-bot-token"
values = {"auth_date": "1714300000", "first_name": "Ada", "id": "424242"}
hash_value = compute_login_widget_hash(bot_token, values)
assert len(hash_value) == 64, f"expected 64 hex chars, got {len(hash_value)}"
assert hash_value == hash_value.lower(), "hash must be lowercase hex"
print("PASS test_login_widget_hash_matches_expected_algorithm")
def test_mini_app_hash_matches_expected_algorithm():
bot_token = "test-bot-token"
values = {
"auth_date": "1714300000",
"user": '{"id":424242,"first_name":"Ada"}',
}
hash_value = compute_mini_app_hash(bot_token, values)
assert len(hash_value) == 64, f"expected 64 hex chars, got {len(hash_value)}"
assert hash_value == hash_value.lower(), "hash must be lowercase hex"
print("PASS test_mini_app_hash_matches_expected_algorithm")
def test_build_login_widget_contains_all_fields():
result = build_login_widget(
bot_token="test-bot-token",
telegram_id=424242,
first_name="Ada",
last_name="Lovelace",
username="ada",
photo_url="https://t.me/i/userpic/320/ada.jpg",
auth_date=1714300000,
)
parsed = _parse_init_data(result.query_string)
assert parsed["id"] == "424242"
assert parsed["first_name"] == "Ada"
assert parsed["last_name"] == "Lovelace"
assert parsed["username"] == "ada"
assert urllib.parse.unquote(parsed["photo_url"]) == "https://t.me/i/userpic/320/ada.jpg"
assert parsed["auth_date"] == "1714300000"
assert parsed["hash"] == result.hash
print("PASS test_build_login_widget_contains_all_fields")
def test_build_mini_app_init_data_contains_all_fields():
result = build_mini_app_init_data(
bot_token="test-bot-token",
telegram_id=424242,
first_name="Ada",
last_name="Lovelace",
username="ada",
query_id="AAHdF6IQAAAAAN0XohDhrOrc",
start_param="ref123",
auth_date=1714300000,
)
parsed = _parse_init_data(result.init_data_raw)
assert parsed["auth_date"] == "1714300000"
assert parsed["hash"] == result.hash
assert urllib.parse.unquote(parsed["start_param"]) == "ref123"
assert urllib.parse.unquote(parsed["query_id"]) == "AAHdF6IQAAAAAN0XohDhrOrc"
user = urllib.parse.unquote(parsed["user"])
assert '"id":424242' in user
assert '"first_name":"Ada"' in user
print("PASS test_build_mini_app_init_data_contains_all_fields")
def test_build_mini_app_init_data_with_chat():
result = build_mini_app_init_data(
bot_token="test-bot-token",
telegram_id=424242,
first_name="Ada",
chat_id=-1001234567890,
chat_type="supergroup",
chat_title="Test Club",
auth_date=1714300000,
)
parsed = _parse_init_data(result.init_data_raw)
chat = urllib.parse.unquote(parsed["chat"])
assert '"id":-1001234567890' in chat
assert '"type":"supergroup"' in chat
assert '"title":"Test Club"' in chat
print("PASS test_build_mini_app_init_data_with_chat")
def main():
tests = [
test_login_widget_hash_matches_expected_algorithm,
test_mini_app_hash_matches_expected_algorithm,
test_build_login_widget_contains_all_fields,
test_build_mini_app_init_data_contains_all_fields,
test_build_mini_app_init_data_with_chat,
]
failed = 0
for test in tests:
try:
test()
except Exception as ex:
failed += 1
print(f"FAIL {test.__name__}: {ex}")
print(f"\n{len(tests) - failed}/{len(tests)} tests passed")
sys.exit(0 if failed == 0 else 1)
if __name__ == "__main__":
main()