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