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:
@@ -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 System.Text.Json;
|
||||||
|
using GmRelay.Shared.Telegram;
|
||||||
using GmRelay.Web.Services;
|
using GmRelay.Web.Services;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -14,17 +13,13 @@ public sealed class TelegramAuthServiceTests
|
|||||||
public void Verify_ShouldAcceptValidTelegramPayload()
|
public void Verify_ShouldAcceptValidTelegramPayload()
|
||||||
{
|
{
|
||||||
const string botToken = "test-bot-token";
|
const string botToken = "test-bot-token";
|
||||||
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
|
||||||
var query = CreateQueryCollection(
|
|
||||||
botToken,
|
botToken,
|
||||||
new Dictionary<string, string>
|
424242L,
|
||||||
{
|
"Ada",
|
||||||
["auth_date"] = authDate,
|
"Lovelace",
|
||||||
["first_name"] = "Ada",
|
"ada");
|
||||||
["id"] = "424242",
|
var query = ParseQueryString(result.QueryString);
|
||||||
["last_name"] = "Lovelace",
|
|
||||||
["username"] = "ada"
|
|
||||||
});
|
|
||||||
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
var verified = service.Verify(query, out var telegramId, out var name);
|
var verified = service.Verify(query, out var telegramId, out var name);
|
||||||
@@ -38,22 +33,11 @@ public sealed class TelegramAuthServiceTests
|
|||||||
public void Verify_ShouldRejectTamperedHash()
|
public void Verify_ShouldRejectTamperedHash()
|
||||||
{
|
{
|
||||||
const string botToken = "test-bot-token";
|
const string botToken = "test-bot-token";
|
||||||
var values = new Dictionary<string, string>
|
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(botToken, 424242L, "Ada");
|
||||||
{
|
var tamperedQuery = ParseQueryString(result.QueryString.Replace("hash=", "hash=00", StringComparison.Ordinal));
|
||||||
["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 service = new TelegramAuthService(CreateConfiguration(botToken));
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
var verified = service.Verify(invalidQuery, out _, out _);
|
var verified = service.Verify(tamperedQuery, out _, out _);
|
||||||
|
|
||||||
Assert.False(verified);
|
Assert.False(verified);
|
||||||
}
|
}
|
||||||
@@ -62,15 +46,13 @@ public sealed class TelegramAuthServiceTests
|
|||||||
public void Verify_ShouldRejectExpiredPayload()
|
public void Verify_ShouldRejectExpiredPayload()
|
||||||
{
|
{
|
||||||
const string botToken = "test-bot-token";
|
const string botToken = "test-bot-token";
|
||||||
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString();
|
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
|
||||||
var query = CreateQueryCollection(
|
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
|
||||||
botToken,
|
botToken,
|
||||||
new Dictionary<string, string>
|
424242L,
|
||||||
{
|
"Ada",
|
||||||
["auth_date"] = expiredAuthDate,
|
authDate: expiredAuthDate);
|
||||||
["first_name"] = "Ada",
|
var query = ParseQueryString(result.QueryString);
|
||||||
["id"] = "424242"
|
|
||||||
});
|
|
||||||
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
var verified = service.Verify(query, out _, out _);
|
var verified = service.Verify(query, out _, out _);
|
||||||
@@ -82,17 +64,16 @@ public sealed class TelegramAuthServiceTests
|
|||||||
public void VerifyWebAppInitData_ShouldAcceptValidTelegramWebAppPayload()
|
public void VerifyWebAppInitData_ShouldAcceptValidTelegramWebAppPayload()
|
||||||
{
|
{
|
||||||
const string botToken = "test-bot-token";
|
const string botToken = "test-bot-token";
|
||||||
var initData = CreateWebAppInitData(
|
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
|
||||||
botToken,
|
botToken,
|
||||||
new Dictionary<string, string>
|
424242L,
|
||||||
{
|
"Ada",
|
||||||
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
|
"Lovelace",
|
||||||
["query_id"] = "AAHdF6IQAAAAAN0XohDhrOrc",
|
"ada",
|
||||||
["user"] = """{"id":424242,"first_name":"Ada","last_name":"Lovelace","username":"ada"}"""
|
queryId: "AAHdF6IQAAAAAN0XohDhrOrc");
|
||||||
});
|
|
||||||
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
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.True(verified);
|
||||||
Assert.Equal(424242L, telegramId);
|
Assert.Equal(424242L, telegramId);
|
||||||
@@ -103,14 +84,8 @@ public sealed class TelegramAuthServiceTests
|
|||||||
public void VerifyWebAppInitData_ShouldRejectTamperedHash()
|
public void VerifyWebAppInitData_ShouldRejectTamperedHash()
|
||||||
{
|
{
|
||||||
const string botToken = "test-bot-token";
|
const string botToken = "test-bot-token";
|
||||||
var initData = CreateWebAppInitData(
|
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(botToken, 424242L, "Ada");
|
||||||
botToken,
|
var tamperedInitData = result.InitDataRaw.Replace("hash=", "hash=00", StringComparison.Ordinal);
|
||||||
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 service = new TelegramAuthService(CreateConfiguration(botToken));
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
var verified = service.VerifyWebAppInitData(tamperedInitData, out _, out _);
|
var verified = service.VerifyWebAppInitData(tamperedInitData, out _, out _);
|
||||||
@@ -122,16 +97,15 @@ public sealed class TelegramAuthServiceTests
|
|||||||
public void VerifyWebAppInitData_ShouldRejectExpiredPayload()
|
public void VerifyWebAppInitData_ShouldRejectExpiredPayload()
|
||||||
{
|
{
|
||||||
const string botToken = "test-bot-token";
|
const string botToken = "test-bot-token";
|
||||||
var initData = CreateWebAppInitData(
|
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
|
||||||
|
var result = TelegramAuthPayloadBuilder.BuildMiniAppInitData(
|
||||||
botToken,
|
botToken,
|
||||||
new Dictionary<string, string>
|
424242L,
|
||||||
{
|
"Ada",
|
||||||
["auth_date"] = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString(),
|
authDate: expiredAuthDate);
|
||||||
["user"] = """{"id":424242,"first_name":"Ada"}"""
|
|
||||||
});
|
|
||||||
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
var verified = service.VerifyWebAppInitData(initData, out _, out _);
|
var verified = service.VerifyWebAppInitData(result.InitDataRaw, out _, out _);
|
||||||
|
|
||||||
Assert.False(verified);
|
Assert.False(verified);
|
||||||
}
|
}
|
||||||
@@ -141,23 +115,22 @@ public sealed class TelegramAuthServiceTests
|
|||||||
{
|
{
|
||||||
const string botToken = "test-bot-token";
|
const string botToken = "test-bot-token";
|
||||||
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
var values = new Dictionary<string, string>
|
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
|
||||||
{
|
botToken,
|
||||||
["auth_date"] = authDate.ToString(),
|
424242L,
|
||||||
["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,
|
|
||||||
"Ada",
|
"Ada",
|
||||||
"Lovelace",
|
"Lovelace",
|
||||||
"ada",
|
"ada",
|
||||||
"https://t.me/i/userpic/320/ada.jpg",
|
"https://t.me/i/userpic/320/ada.jpg",
|
||||||
authDate,
|
authDate);
|
||||||
ComputeTelegramHash(botToken, values));
|
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 service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
var verified = service.VerifyLoginPayload(payload, out var telegramId, out var name);
|
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";
|
const string botToken = "test-bot-token";
|
||||||
var authDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
|
var authDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds();
|
||||||
var values = new Dictionary<string, string>
|
var result = TelegramAuthPayloadBuilder.BuildLoginWidget(
|
||||||
{
|
botToken,
|
||||||
["auth_date"] = authDate.ToString(),
|
424242L,
|
||||||
["first_name"] = "Ada",
|
|
||||||
["id"] = "424242"
|
|
||||||
};
|
|
||||||
var payload = new TelegramLoginPayload(
|
|
||||||
424242,
|
|
||||||
"Ada",
|
"Ada",
|
||||||
null,
|
authDate: authDate);
|
||||||
null,
|
var payload = new TelegramLoginPayload(
|
||||||
null,
|
result.TelegramId,
|
||||||
authDate,
|
result.FirstName,
|
||||||
ComputeTelegramHash(botToken, values));
|
result.LastName,
|
||||||
|
result.Username,
|
||||||
|
result.PhotoUrl,
|
||||||
|
result.AuthDate,
|
||||||
|
result.Hash);
|
||||||
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
var verified = service.VerifyLoginPayload(payload, out _, out _);
|
var verified = service.VerifyLoginPayload(payload, out _, out _);
|
||||||
@@ -263,48 +235,11 @@ public sealed class TelegramAuthServiceTests
|
|||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
private static QueryCollection CreateQueryCollection(string botToken, Dictionary<string, string> values)
|
private static QueryCollection ParseQueryString(string queryString)
|
||||||
{
|
{
|
||||||
var hash = ComputeTelegramHash(botToken, values);
|
var parsed = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
var queryValues = values.ToDictionary(
|
return new QueryCollection(parsed.ToDictionary(
|
||||||
pair => pair.Key,
|
pair => pair.Key,
|
||||||
pair => new StringValues(pair.Value));
|
pair => 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user