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,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 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<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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 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<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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<string, string> 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<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();
|
||||
pair => pair.Value));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user