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
@@ -0,0 +1,198 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace GmRelay.Shared.Telegram;
/// <summary>
/// Generates Telegram authentication payloads that pass the validation performed by
/// <see cref="GmRelay.Web.Services.TelegramAuthService"/>.
///
/// Useful for tests and local E2E runners that need a valid Telegram user identity without
/// talking to real Telegram servers.
/// </summary>
public static class TelegramAuthPayloadBuilder
{
/// <summary>
/// Builds a Telegram Login Widget query string and hash.
/// The resulting query can be sent to the widget callback endpoint.
/// </summary>
public static LoginWidgetResult BuildLoginWidget(
string botToken,
long telegramId,
string firstName,
string? lastName = null,
string? username = null,
string? photoUrl = null,
long? authDate = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(botToken);
ArgumentException.ThrowIfNullOrWhiteSpace(firstName);
var timestamp = authDate ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["auth_date"] = timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture),
["first_name"] = firstName,
["id"] = telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
};
if (!string.IsNullOrWhiteSpace(lastName))
values["last_name"] = lastName;
if (!string.IsNullOrWhiteSpace(photoUrl))
values["photo_url"] = photoUrl;
if (!string.IsNullOrWhiteSpace(username))
values["username"] = username;
var hash = ComputeLoginWidgetHash(botToken, values);
values["hash"] = hash;
var queryString = string.Join(
"&",
values.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
return new LoginWidgetResult(
telegramId,
firstName,
lastName,
username,
photoUrl,
timestamp,
hash,
queryString);
}
/// <summary>
/// Builds a Telegram Mini App initData raw string (the value passed in the WebApp URL hash).
/// </summary>
public static MiniAppInitDataResult BuildMiniAppInitData(
string botToken,
long telegramId,
string firstName,
string? lastName = null,
string? username = null,
string? photoUrl = null,
string? languageCode = null,
bool isPremium = false,
long? chatId = null,
string? chatType = null,
string? chatTitle = null,
string? queryId = null,
string? startParam = null,
long? authDate = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(botToken);
ArgumentException.ThrowIfNullOrWhiteSpace(firstName);
var userPayload = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["id"] = telegramId,
["first_name"] = firstName,
["last_name"] = lastName,
["username"] = username,
["photo_url"] = photoUrl,
["language_code"] = languageCode,
["is_premium"] = isPremium ? true : null
};
// Remove null values to match real Telegram initData serialization.
var userJson = JsonSerializer.Serialize(
userPayload.Where(kv => kv.Value is not null).ToDictionary(kv => kv.Key, kv => kv.Value),
JsonSerializerOptions.Web);
var timestamp = authDate ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["auth_date"] = timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture),
["user"] = userJson
};
if (!string.IsNullOrWhiteSpace(queryId))
values["query_id"] = queryId;
if (!string.IsNullOrWhiteSpace(startParam))
values["start_param"] = startParam;
if (chatId.HasValue)
{
values["chat"] = JsonSerializer.Serialize(new
{
id = chatId.Value,
type = chatType ?? "private",
title = chatTitle
}, JsonSerializerOptions.Web);
}
var hash = ComputeMiniAppHash(botToken, values);
var pairs = values
.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")
.Append($"hash={hash}");
var initDataRaw = string.Join("&", pairs);
return new MiniAppInitDataResult(
telegramId,
firstName,
lastName,
username,
photoUrl,
timestamp,
hash,
initDataRaw);
}
/// <summary>
/// Computes the HMAC-SHA256 hash used by Telegram Login Widget callbacks.
/// </summary>
public static string ComputeLoginWidgetHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.Where(pair => pair.Key != "hash")
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
/// <summary>
/// Computes the HMAC-SHA256 hash used by Telegram Mini App initData.
/// </summary>
public static string ComputeMiniAppHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.Where(pair => pair.Key != "hash")
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
public sealed record LoginWidgetResult(
long TelegramId,
string FirstName,
string? LastName,
string? Username,
string? PhotoUrl,
long AuthDate,
string Hash,
string QueryString);
public sealed record MiniAppInitDataResult(
long TelegramId,
string FirstName,
string? LastName,
string? Username,
string? PhotoUrl,
long AuthDate,
string Hash,
string InitDataRaw);
@@ -0,0 +1,223 @@
using System.Security.Cryptography;
using System.Text;
using GmRelay.Shared.Telegram;
using GmRelay.Web.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
namespace GmRelay.Bot.Tests.Web;
public sealed class TelegramAuthPayloadBuilderTests
{
[Fact]
public void BuildLoginWidget_ShouldGeneratePayloadAcceptedByAuthService()
{
const string botToken = "test-bot-token";
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
424242L,
"Ada",
"Lovelace",
"ada");
var query = ParseQueryString(result.QueryString);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
Assert.Equal("Ada Lovelace", name);
}
[Fact]
public void BuildLoginWidget_ShouldBeRejectedWhenTampered()
{
const string botToken = "test-bot-token";
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(botToken, 424242L, "Ada");
var tamperedQuery = ParseQueryString(result.QueryString.Replace("hash=", "hash=00", StringComparison.Ordinal));
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(tamperedQuery, out _, out _);
Assert.False(verified);
}
[Fact]
public void BuildLoginWidget_ShouldBeRejectedWhenExpired()
{
const string botToken = "test-bot-token";
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
424242L,
"Ada",
authDate: expiredAuthDate);
var query = ParseQueryString(result.QueryString);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out _, out _);
Assert.False(verified);
}
[Fact]
public void BuildMiniAppInitData_ShouldGeneratePayloadAcceptedByAuthService()
{
const string botToken = "test-bot-token";
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken,
424242L,
"Ada",
"Lovelace",
"ada");
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
Assert.Equal("Ada Lovelace", name);
}
[Fact]
public void BuildMiniAppInitData_ShouldBeRejectedWhenTampered()
{
const string botToken = "test-bot-token";
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(botToken, 424242L, "Ada");
var tamperedInitData = result.InitDataRaw.Replace("hash=", "hash=00", StringComparison.Ordinal);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(tamperedInitData, out _, out _);
Assert.False(verified);
}
[Fact]
public void BuildMiniAppInitData_ShouldBeRejectedWhenExpired()
{
const string botToken = "test-bot-token";
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken,
424242L,
"Ada",
authDate: expiredAuthDate);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out _, out _);
Assert.False(verified);
}
[Fact]
public void BuildMiniAppInitData_ShouldIncludeOptionalFields()
{
const string botToken = "test-bot-token";
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken,
424242L,
"Ada",
"Lovelace",
"ada",
photoUrl: "https://t.me/i/userpic/320/ada.jpg",
languageCode: "en",
isPremium: true,
chatId: -1001234567890L,
chatType: "supergroup",
chatTitle: "Test Club",
queryId: "AAHdF6IQAAAAAN0XohDhrOrc",
startParam: "ref123");
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
Assert.Equal("Ada Lovelace", name);
Assert.Contains("start_param=ref123", result.InitDataRaw, StringComparison.Ordinal);
Assert.Contains("query_id=", result.InitDataRaw, StringComparison.Ordinal);
Assert.Contains("chat=%7B%22id%22%3A-1001234567890", result.InitDataRaw, StringComparison.Ordinal);
}
[Fact]
public void ComputeLoginWidgetHash_ShouldMatchTelegramAuthServiceExpectations()
{
const string botToken = "test-bot-token";
var values = new Dictionary<string, string>
{
["auth_date"] = "1714300000",
["first_name"] = "Ada",
["id"] = "424242"
};
var hash = TelegramAuthPayloadBuilder.ComputeLoginWidgetHash(botToken, values);
var recomputed = ComputeLegacyTelegramHash(botToken, values);
Assert.Equal(recomputed, hash);
}
[Fact]
public void ComputeMiniAppHash_ShouldMatchTelegramAuthServiceExpectations()
{
const string botToken = "test-bot-token";
var values = new Dictionary<string, string>
{
["auth_date"] = "1714300000",
["user"] = """{"id":424242,"first_name":"Ada"}"""
};
var hash = TelegramAuthPayloadBuilder.ComputeMiniAppHash(botToken, values);
var recomputed = ComputeLegacyWebAppHash(botToken, values);
Assert.Equal(recomputed, hash);
}
private static IConfiguration CreateConfiguration(string botToken) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Telegram:BotToken"] = botToken
})
.Build();
private static QueryCollection ParseQueryString(string queryString)
{
var parsed = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
return new QueryCollection(parsed.ToDictionary(
pair => pair.Key,
pair => pair.Value));
}
// Legacy inline computation kept to prove the builder matches the original algorithm.
private static string ComputeLegacyTelegramHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static string ComputeLegacyWebAppHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
@@ -1,6 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GmRelay.Shared.Telegram;
using GmRelay.Web.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
@@ -14,17 +13,13 @@ public sealed class TelegramAuthServiceTests
public void Verify_ShouldAcceptValidTelegramPayload()
{
const string botToken = "test-bot-token";
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var query = CreateQueryCollection(
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
new Dictionary<string, string>
{
["auth_date"] = authDate,
["first_name"] = "Ada",
["id"] = "424242",
["last_name"] = "Lovelace",
["username"] = "ada"
});
424242L,
"Ada",
"Lovelace",
"ada");
var query = ParseQueryString(result.QueryString);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out var telegramId, out var name);
@@ -38,22 +33,11 @@ public sealed class TelegramAuthServiceTests
public void Verify_ShouldRejectTamperedHash()
{
const string botToken = "test-bot-token";
var values = new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["first_name"] = "Ada",
["id"] = "424242"
};
var query = CreateQueryCollection(botToken, values);
var invalidQuery = new QueryCollection(new Dictionary<string, StringValues>(query.ToDictionary(
pair => pair.Key,
pair => pair.Value))
{
["hash"] = "00"
});
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(botToken, 424242L, "Ada");
var tamperedQuery = ParseQueryString(result.QueryString.Replace("hash=", "hash=00", StringComparison.Ordinal));
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(invalidQuery, out _, out _);
var verified = service.Verify(tamperedQuery, out _, out _);
Assert.False(verified);
}
@@ -62,15 +46,13 @@ public sealed class TelegramAuthServiceTests
public void Verify_ShouldRejectExpiredPayload()
{
const string botToken = "test-bot-token";
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString();
var query = CreateQueryCollection(
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
new Dictionary<string, string>
{
["auth_date"] = expiredAuthDate,
["first_name"] = "Ada",
["id"] = "424242"
});
424242L,
"Ada",
authDate: expiredAuthDate);
var query = ParseQueryString(result.QueryString);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.Verify(query, out _, out _);
@@ -82,17 +64,16 @@ public sealed class TelegramAuthServiceTests
public void VerifyWebAppInitData_ShouldAcceptValidTelegramWebAppPayload()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["query_id"] = "AAHdF6IQAAAAAN0XohDhrOrc",
["user"] = """{"id":424242,"first_name":"Ada","last_name":"Lovelace","username":"ada"}"""
});
424242L,
"Ada",
"Lovelace",
"ada",
queryId: "AAHdF6IQAAAAAN0XohDhrOrc");
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(initData, out var telegramId, out var name);
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out var telegramId, out var name);
Assert.True(verified);
Assert.Equal(424242L, telegramId);
@@ -103,14 +84,8 @@ public sealed class TelegramAuthServiceTests
public void VerifyWebAppInitData_ShouldRejectTamperedHash()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
["user"] = """{"id":424242,"first_name":"Ada"}"""
});
var tamperedInitData = initData.Replace("hash=", "hash=00", StringComparison.Ordinal);
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(botToken, 424242L, "Ada");
var tamperedInitData = result.InitDataRaw.Replace("hash=", "hash=00", StringComparison.Ordinal);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(tamperedInitData, out _, out _);
@@ -122,16 +97,15 @@ public sealed class TelegramAuthServiceTests
public void VerifyWebAppInitData_ShouldRejectExpiredPayload()
{
const string botToken = "test-bot-token";
var initData = CreateWebAppInitData(
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
botToken,
new Dictionary<string, string>
{
["auth_date"] = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString(),
["user"] = """{"id":424242,"first_name":"Ada"}"""
});
424242L,
"Ada",
authDate: expiredAuthDate);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyWebAppInitData(initData, out _, out _);
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out _, out _);
Assert.False(verified);
}
@@ -141,23 +115,22 @@ public sealed class TelegramAuthServiceTests
{
const string botToken = "test-bot-token";
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var values = new Dictionary<string, string>
{
["auth_date"] = authDate.ToString(),
["first_name"] = "Ada",
["id"] = "424242",
["last_name"] = "Lovelace",
["photo_url"] = "https://t.me/i/userpic/320/ada.jpg",
["username"] = "ada"
};
var payload = new TelegramLoginPayload(
424242,
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
424242L,
"Ada",
"Lovelace",
"ada",
"https://t.me/i/userpic/320/ada.jpg",
authDate,
ComputeTelegramHash(botToken, values));
authDate);
var payload = new TelegramLoginPayload(
result.TelegramId,
result.FirstName,
result.LastName,
result.Username,
result.PhotoUrl,
result.AuthDate,
result.Hash);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyLoginPayload(payload, out var telegramId, out var name);
@@ -190,20 +163,19 @@ public sealed class TelegramAuthServiceTests
{
const string botToken = "test-bot-token";
var authDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
var values = new Dictionary<string, string>
{
["auth_date"] = authDate.ToString(),
["first_name"] = "Ada",
["id"] = "424242"
};
var payload = new TelegramLoginPayload(
424242,
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
botToken,
424242L,
"Ada",
null,
null,
null,
authDate,
ComputeTelegramHash(botToken, values));
authDate: authDate);
var payload = new TelegramLoginPayload(
result.TelegramId,
result.FirstName,
result.LastName,
result.Username,
result.PhotoUrl,
result.AuthDate,
result.Hash);
var service = new TelegramAuthService(CreateConfiguration(botToken));
var verified = service.VerifyLoginPayload(payload, out _, out _);
@@ -263,48 +235,11 @@ public sealed class TelegramAuthServiceTests
})
.Build();
private static QueryCollection CreateQueryCollection(string botToken, Dictionary<string, string> values)
private static QueryCollection ParseQueryString(string queryString)
{
var hash = ComputeTelegramHash(botToken, values);
var queryValues = values.ToDictionary(
var parsed = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
return new QueryCollection(parsed.ToDictionary(
pair => pair.Key,
pair => new StringValues(pair.Value));
queryValues["hash"] = new StringValues(hash);
return new QueryCollection(queryValues);
}
private static string ComputeTelegramHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static string CreateWebAppInitData(string botToken, IReadOnlyDictionary<string, string> values)
{
var hash = ComputeTelegramWebAppHash(botToken, values);
var encodedPairs = values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")
.Append($"hash={hash}");
return string.Join("&", encodedPairs);
}
private static string ComputeTelegramWebAppHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
pair => pair.Value));
}
}
+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()