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);
|
||||
Reference in New Issue
Block a user