diff --git a/src/GmRelay.Shared/Telegram/TelegramAuthPayloadBuilder.cs b/src/GmRelay.Shared/Telegram/TelegramAuthPayloadBuilder.cs
new file mode 100644
index 0000000..912bbcf
--- /dev/null
+++ b/src/GmRelay.Shared/Telegram/TelegramAuthPayloadBuilder.cs
@@ -0,0 +1,198 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace GmRelay.Shared.Telegram;
+
+///
+/// Generates Telegram authentication payloads that pass the validation performed by
+/// .
+///
+/// Useful for tests and local E2E runners that need a valid Telegram user identity without
+/// talking to real Telegram servers.
+///
+public static class TelegramAuthPayloadBuilder
+{
+ ///
+ /// Builds a Telegram Login Widget query string and hash.
+ /// The resulting query can be sent to the widget callback endpoint.
+ ///
+ 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(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);
+ }
+
+ ///
+ /// Builds a Telegram Mini App initData raw string (the value passed in the WebApp URL hash).
+ ///
+ 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(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(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);
+ }
+
+ ///
+ /// Computes the HMAC-SHA256 hash used by Telegram Login Widget callbacks.
+ ///
+ public static string ComputeLoginWidgetHash(string botToken, IReadOnlyDictionary 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();
+ }
+
+ ///
+ /// Computes the HMAC-SHA256 hash used by Telegram Mini App initData.
+ ///
+ public static string ComputeMiniAppHash(string botToken, IReadOnlyDictionary 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);
diff --git a/tests/GmRelay.Bot.Tests/Web/TelegramAuthPayloadBuilderTests.cs b/tests/GmRelay.Bot.Tests/Web/TelegramAuthPayloadBuilderTests.cs
new file mode 100644
index 0000000..6dc00ce
--- /dev/null
+++ b/tests/GmRelay.Bot.Tests/Web/TelegramAuthPayloadBuilderTests.cs
@@ -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
+ {
+ ["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
+ {
+ ["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
+ {
+ ["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 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 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();
+ }
+}
diff --git a/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs
index 252caf5..f2d94af 100644
--- a/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs
+++ b/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs
@@ -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
- {
- ["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
- {
- ["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
- ["first_name"] = "Ada",
- ["id"] = "424242"
- };
- var query = CreateQueryCollection(botToken, values);
- var invalidQuery = new QueryCollection(new Dictionary(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
- {
- ["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
- {
- ["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
- {
- ["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
- {
- ["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
- {
- ["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
- {
- ["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 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 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 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 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));
}
}
diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore
new file mode 100644
index 0000000..4e750fa
--- /dev/null
+++ b/tests/e2e/.gitignore
@@ -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
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
new file mode 100644
index 0000000..8d6e605
--- /dev/null
+++ b/tests/e2e/README.md
@@ -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
diff --git a/tests/e2e/helpers/telegram_init_data.py b/tests/e2e/helpers/telegram_init_data.py
new file mode 100644
index 0000000..9a73e94
--- /dev/null
+++ b/tests/e2e/helpers/telegram_init_data.py
@@ -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,
+ )
diff --git a/tests/e2e/helpers/test_telegram_init_data.py b/tests/e2e/helpers/test_telegram_init_data.py
new file mode 100644
index 0000000..235fcc2
--- /dev/null
+++ b/tests/e2e/helpers/test_telegram_init_data.py
@@ -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()