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

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

Closes #144

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 11:53:08 +03:00
parent 6a59c48348
commit 5319592964
7 changed files with 893 additions and 124 deletions
@@ -0,0 +1,198 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace GmRelay.Shared.Telegram;
/// <summary>
/// Generates Telegram authentication payloads that pass the validation performed by
/// <see cref="GmRelay.Web.Services.TelegramAuthService"/>.
///
/// Useful for tests and local E2E runners that need a valid Telegram user identity without
/// talking to real Telegram servers.
/// </summary>
public static class TelegramAuthPayloadBuilder
{
/// <summary>
/// Builds a Telegram Login Widget query string and hash.
/// The resulting query can be sent to the widget callback endpoint.
/// </summary>
public static LoginWidgetResult BuildLoginWidget(
string botToken,
long telegramId,
string firstName,
string? lastName = null,
string? username = null,
string? photoUrl = null,
long? authDate = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(botToken);
ArgumentException.ThrowIfNullOrWhiteSpace(firstName);
var timestamp = authDate ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["auth_date"] = timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture),
["first_name"] = firstName,
["id"] = telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
};
if (!string.IsNullOrWhiteSpace(lastName))
values["last_name"] = lastName;
if (!string.IsNullOrWhiteSpace(photoUrl))
values["photo_url"] = photoUrl;
if (!string.IsNullOrWhiteSpace(username))
values["username"] = username;
var hash = ComputeLoginWidgetHash(botToken, values);
values["hash"] = hash;
var queryString = string.Join(
"&",
values.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
return new LoginWidgetResult(
telegramId,
firstName,
lastName,
username,
photoUrl,
timestamp,
hash,
queryString);
}
/// <summary>
/// Builds a Telegram Mini App initData raw string (the value passed in the WebApp URL hash).
/// </summary>
public static MiniAppInitDataResult BuildMiniAppInitData(
string botToken,
long telegramId,
string firstName,
string? lastName = null,
string? username = null,
string? photoUrl = null,
string? languageCode = null,
bool isPremium = false,
long? chatId = null,
string? chatType = null,
string? chatTitle = null,
string? queryId = null,
string? startParam = null,
long? authDate = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(botToken);
ArgumentException.ThrowIfNullOrWhiteSpace(firstName);
var userPayload = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["id"] = telegramId,
["first_name"] = firstName,
["last_name"] = lastName,
["username"] = username,
["photo_url"] = photoUrl,
["language_code"] = languageCode,
["is_premium"] = isPremium ? true : null
};
// Remove null values to match real Telegram initData serialization.
var userJson = JsonSerializer.Serialize(
userPayload.Where(kv => kv.Value is not null).ToDictionary(kv => kv.Key, kv => kv.Value),
JsonSerializerOptions.Web);
var timestamp = authDate ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["auth_date"] = timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture),
["user"] = userJson
};
if (!string.IsNullOrWhiteSpace(queryId))
values["query_id"] = queryId;
if (!string.IsNullOrWhiteSpace(startParam))
values["start_param"] = startParam;
if (chatId.HasValue)
{
values["chat"] = JsonSerializer.Serialize(new
{
id = chatId.Value,
type = chatType ?? "private",
title = chatTitle
}, JsonSerializerOptions.Web);
}
var hash = ComputeMiniAppHash(botToken, values);
var pairs = values
.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")
.Append($"hash={hash}");
var initDataRaw = string.Join("&", pairs);
return new MiniAppInitDataResult(
telegramId,
firstName,
lastName,
username,
photoUrl,
timestamp,
hash,
initDataRaw);
}
/// <summary>
/// Computes the HMAC-SHA256 hash used by Telegram Login Widget callbacks.
/// </summary>
public static string ComputeLoginWidgetHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.Where(pair => pair.Key != "hash")
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
/// <summary>
/// Computes the HMAC-SHA256 hash used by Telegram Mini App initData.
/// </summary>
public static string ComputeMiniAppHash(string botToken, IReadOnlyDictionary<string, string> values)
{
var dataCheckString = string.Join(
"\n",
values
.Where(pair => pair.Key != "hash")
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => $"{pair.Key}={pair.Value}"));
var secretKey = HMACSHA256.HashData(Encoding.UTF8.GetBytes("WebAppData"), Encoding.UTF8.GetBytes(botToken));
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
public sealed record LoginWidgetResult(
long TelegramId,
string FirstName,
string? LastName,
string? Username,
string? PhotoUrl,
long AuthDate,
string Hash,
string QueryString);
public sealed record MiniAppInitDataResult(
long TelegramId,
string FirstName,
string? LastName,
string? Username,
string? PhotoUrl,
long AuthDate,
string Hash,
string InitDataRaw);