diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fa96afc..ab111e9 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.9.2 + VERSION: 1.9.3 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index c7f7e05..a46af43 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.9.2 + 1.9.3 net10.0 preview enable diff --git a/README.md b/README.md index e279564..55c4476 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.9.2`. +**Текущая версия:** `v1.9.3`. --- @@ -24,7 +24,7 @@ ### 🌐 Web Dashboard (Blazor Server) - **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация). -- **📱 Telegram Mini App Dashboard**: Мобильная версия dashboard открывается прямо из Telegram, проверяет WebApp `initData` на сервере и использует те же права owner/co-GM, что и обычный Web Dashboard. Mini App ждёт данные Telegram при старте и автоматически обновляет состояние входа после внешнего Telegram Login, включая возврат на страницу `/login`. +- **📱 Telegram Mini App Dashboard**: Мобильная версия dashboard открывается прямо из Telegram, проверяет WebApp `initData` на сервере и использует те же права owner/co-GM, что и обычный Web Dashboard. Если Mini App попадает в fallback-вход, Telegram Login Widget авторизует пользователя callback-запросом внутри текущего WebView, а интерфейс учитывает safe-area телефона и верхнюю панель Telegram. - **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов. - **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard. - **📋 Шаблоны кампаний**: Owner и co-GM управляют типовыми параметрами кампаний в отдельной вкладке `Шаблоны`, а на странице группы запускают новый повторяющийся batch из выбранного шаблона. @@ -88,7 +88,7 @@ GMRELAY_WEB_PORT=8080 *(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте. -Для Telegram Mini App настройте в @BotFather домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`. Бот также показывает кнопку `Открыть dashboard` в ответе на `/start`, если переменная задана. +Для Telegram Mini App настройте в @BotFather домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`. Бот также показывает кнопку `Открыть dashboard` в ответе на `/start`, если переменная задана. Начиная с v1.9.3 дополнительных действий в BotFather для фикса входа не требуется: URL остаётся тем же HTTPS-адресом `/miniapp`, а fallback-вход выполняется внутри активного Telegram WebView. ### 3. Запуск Выполните команду: @@ -183,6 +183,8 @@ Owner и co-GM могут открыть мобильный dashboard прямо После входа Mini App использует те же страницы, что и Web Dashboard: список групп, карточки сессий, редактирование игры, повышение игрока из листа ожидания, применение шаблонов и bulk-операции batch. Доступ к чужим группам не появляется: все данные по-прежнему фильтруются через `AuthorizedSessionService` по роли owner/co-GM. +Если `Telegram.WebApp.initData` недоступен или серверная проверка Mini App не прошла, `/miniapp` показывает диагностичное состояние и fallback-кнопку входа. Fallback больше не зависит от внешнего redirect: Telegram Login Widget вызывает callback на странице, отправляет payload на `/auth/telegram-login`, получает cookie в текущем WebView и сразу возвращает пользователя в dashboard. + ### Другие команды - `/listsessions` — Показать список всех актуальных игр в этой группе. - `⏰ Перенести` в сообщении расписания — Запустить голосование по 2-3 вариантам нового времени. diff --git a/compose.yaml b/compose.yaml index ab1e00a..8f60371 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.2 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.3 restart: always depends_on: db: @@ -30,7 +30,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.2 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.3 restart: always depends_on: db: diff --git a/src/GmRelay.Web/Components/App.razor b/src/GmRelay.Web/Components/App.razor index 2b81d66..374c17b 100644 --- a/src/GmRelay.Web/Components/App.razor +++ b/src/GmRelay.Web/Components/App.razor @@ -24,6 +24,22 @@ diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 599c090..ed923b2 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/src/GmRelay.Web/Components/Pages/Login.razor b/src/GmRelay.Web/Components/Pages/Login.razor index fd60659..85ea5b8 100644 --- a/src/GmRelay.Web/Components/Pages/Login.razor +++ b/src/GmRelay.Web/Components/Pages/Login.razor @@ -25,7 +25,6 @@ @code { private string BotUsername => Configuration["Telegram:BotUsername"] ?? "GmRelayBot"; - private string AuthUrl => Navigation.ToAbsoluteUri("/auth/telegram").ToString(); [CascadingParameter] private Task? AuthStateTask { get; set; } @@ -46,8 +45,8 @@ { if (firstRender) { - await JS.InvokeVoidAsync("loadTelegramWidget", BotUsername, AuthUrl); - await JS.InvokeVoidAsync("watchTelegramMiniAppLogin", "/auth/status", "/", true); + await JS.InvokeVoidAsync("loadTelegramWidget", BotUsername, "/auth/telegram-login"); + await JS.InvokeVoidAsync("watchTelegramMiniAppLogin", "/auth/status", "/", false); } } } diff --git a/src/GmRelay.Web/Components/Pages/MiniApp.razor b/src/GmRelay.Web/Components/Pages/MiniApp.razor index b4dcd09..620e26c 100644 --- a/src/GmRelay.Web/Components/Pages/MiniApp.razor +++ b/src/GmRelay.Web/Components/Pages/MiniApp.razor @@ -1,12 +1,13 @@ @page "/miniapp" @using Microsoft.AspNetCore.Components.Authorization +@using System.Text.Json.Serialization @inject IJSRuntime JS @inject NavigationManager Navigation Mini App Dashboard — GM-Relay
-
+

GM-Relay

@statusMessage

@@ -20,6 +21,7 @@ @code { private string statusMessage = "Открываем dashboard внутри Telegram..."; + private string miniAppAuthStatus = "starting"; private bool showFallback; [CascadingParameter] @@ -48,14 +50,17 @@ try { - var authenticated = await JS.InvokeAsync( + var result = await JS.InvokeAsync( "authenticateTelegramMiniApp", "/auth/telegram-webapp", "/"); - if (!authenticated) + if (!result.Authenticated) { - statusMessage = "Mini App доступен из Telegram. Для браузера используйте обычный вход."; + miniAppAuthStatus = string.IsNullOrWhiteSpace(result.Reason) + ? "telegram-auth-failed" + : result.Reason; + statusMessage = GetStatusMessage(miniAppAuthStatus); showFallback = true; StateHasChanged(); await TryWatchLoginAsync(); @@ -63,6 +68,7 @@ } catch (JSException) { + miniAppAuthStatus = "telegram-auth-failed"; statusMessage = "Не удалось получить данные Telegram Mini App. Попробуйте открыть dashboard из бота."; showFallback = true; StateHasChanged(); @@ -80,4 +86,18 @@ { } } + + private static string GetStatusMessage(string reason) => reason switch + { + "telegram-webapp-missing" => "Mini App API не найден. Если страница открыта в браузере, войдите через Telegram.", + "telegram-init-data-empty" => "Telegram открыл страницу без Mini App initData. Попробуйте войти через Telegram на этом экране.", + "telegram-auth-failed" => "Не удалось проверить Telegram Mini App. Попробуйте войти через Telegram.", + _ => "Mini App доступен из Telegram. Для браузера используйте обычный вход." + }; + + private sealed record MiniAppAuthResult( + [property: JsonPropertyName("authenticated")] bool Authenticated, + [property: JsonPropertyName("reason")] string? Reason, + [property: JsonPropertyName("status")] int? Status, + [property: JsonPropertyName("redirectUrl")] string? RedirectUrl); } diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 89c751c..a747dd6 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -117,6 +117,25 @@ app.MapPost("/auth/telegram-webapp", async ( return Results.Ok(new { redirectUrl = "/" }); }).DisableAntiforgery(); +app.MapPost("/auth/telegram-login", async ( + HttpContext context, + TelegramAuthService authService, + TelegramLoginPayload request) => +{ + if (!authService.VerifyLoginPayload(request, out var telegramId, out var name)) + { + return Results.Unauthorized(); + } + + var authProperties = new AuthenticationProperties { IsPersistent = true }; + await context.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + CreateTelegramPrincipal(telegramId, name), + authProperties); + + return Results.Ok(new { redirectUrl = "/" }); +}).DisableAntiforgery(); + app.MapGet("/auth/status", (HttpContext context) => Results.Ok(new { authenticated = context.User.Identity?.IsAuthenticated == true })); diff --git a/src/GmRelay.Web/Services/TelegramAuthService.cs b/src/GmRelay.Web/Services/TelegramAuthService.cs index a6dbb4a..0d8a7aa 100644 --- a/src/GmRelay.Web/Services/TelegramAuthService.cs +++ b/src/GmRelay.Web/Services/TelegramAuthService.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.WebUtilities; namespace GmRelay.Web.Services; @@ -113,6 +114,69 @@ public sealed class TelegramAuthService(IConfiguration configuration) return TryReadWebAppUser(userJson.ToString(), out telegramId, out name); } + public bool VerifyLoginPayload(TelegramLoginPayload payload, out long telegramId, out string name) + { + telegramId = 0; + name = string.Empty; + + if (payload.Id <= 0 || + string.IsNullOrWhiteSpace(payload.FirstName) || + payload.AuthDate <= 0 || + string.IsNullOrWhiteSpace(payload.Hash)) + { + return false; + } + + var token = configuration["Telegram__BotToken"] ?? configuration["Telegram:BotToken"]; + if (string.IsNullOrEmpty(token)) + return false; + + var values = new SortedDictionary(StringComparer.Ordinal) + { + ["auth_date"] = payload.AuthDate.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["first_name"] = payload.FirstName, + ["id"] = payload.Id.ToString(System.Globalization.CultureInfo.InvariantCulture) + }; + + if (!string.IsNullOrWhiteSpace(payload.LastName)) + values["last_name"] = payload.LastName; + if (!string.IsNullOrWhiteSpace(payload.PhotoUrl)) + values["photo_url"] = payload.PhotoUrl; + if (!string.IsNullOrWhiteSpace(payload.Username)) + values["username"] = payload.Username; + + var dataCheckString = string.Join("\n", values.Select(pair => $"{pair.Key}={pair.Value}")); + var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + var computedHashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString)); + + byte[] hashBytes; + try + { + hashBytes = Convert.FromHexString(payload.Hash); + } + catch (FormatException) + { + return false; + } + catch (ArgumentException) + { + return false; + } + + if (!CryptographicOperations.FixedTimeEquals(computedHashBytes, hashBytes)) + return false; + + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (now - payload.AuthDate > 86400) + return false; + + telegramId = payload.Id; + name = string.IsNullOrWhiteSpace(payload.LastName) + ? payload.FirstName + : $"{payload.FirstName} {payload.LastName}"; + return true; + } + private static bool TryReadWebAppUser(string userJson, out long telegramId, out string name) { telegramId = 0; @@ -152,3 +216,12 @@ public sealed class TelegramAuthService(IConfiguration configuration) } } } + +public sealed record TelegramLoginPayload( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("first_name")] string FirstName, + [property: JsonPropertyName("last_name")] string? LastName, + [property: JsonPropertyName("username")] string? Username, + [property: JsonPropertyName("photo_url")] string? PhotoUrl, + [property: JsonPropertyName("auth_date")] long AuthDate, + [property: JsonPropertyName("hash")] string Hash); diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 2ecc4f6..a244c2d 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.9.2 + GM-Relay Design System v1.9.3 Dark RPG Dashboard Theme ============================================ */ @@ -69,6 +69,21 @@ /* Sidebar */ --sidebar-width: 260px; + + /* Telegram Mini App safe areas */ + --gm-tg-safe-top: var(--tg-safe-area-inset-top, env(safe-area-inset-top, 0px)); + --gm-tg-safe-right: var(--tg-safe-area-inset-right, env(safe-area-inset-right, 0px)); + --gm-tg-safe-bottom: var(--tg-safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); + --gm-tg-safe-left: var(--tg-safe-area-inset-left, env(safe-area-inset-left, 0px)); + --gm-tg-content-safe-top: var(--tg-content-safe-area-inset-top, 0px); + --gm-tg-content-safe-right: var(--tg-content-safe-area-inset-right, 0px); + --gm-tg-content-safe-bottom: var(--tg-content-safe-area-inset-bottom, 0px); + --gm-tg-content-safe-left: var(--tg-content-safe-area-inset-left, 0px); + --gm-mini-app-top-inset: calc(var(--gm-tg-safe-top, 0px) + var(--gm-tg-content-safe-top, 0px)); + --gm-mini-app-bottom-inset: calc(var(--gm-tg-safe-bottom, 0px) + var(--gm-tg-content-safe-bottom, 0px)); + --gm-mini-app-left-inset: calc(var(--gm-tg-safe-left, 0px) + var(--gm-tg-content-safe-left, 0px)); + --gm-mini-app-right-inset: calc(var(--gm-tg-safe-right, 0px) + var(--gm-tg-content-safe-right, 0px)); + --gm-tg-viewport-height: 100dvh; } /* === Reset & Base === */ @@ -845,6 +860,7 @@ select option { /* === Telegram Mini App entry === */ .mini-app-page { min-height: 100vh; + min-height: var(--gm-tg-viewport-height, 100dvh); display: flex; align-items: center; justify-content: center; @@ -882,6 +898,21 @@ select option { max-width: 720px; } +body.telegram-mini-app { + min-height: var(--gm-tg-viewport-height, 100dvh); +} + +body.telegram-mini-app .mini-app-page { + padding-top: calc(1rem + var(--gm-mini-app-top-inset, 0px)); + padding-right: calc(1rem + var(--gm-mini-app-right-inset, 0px)); + padding-bottom: calc(1rem + var(--gm-mini-app-bottom-inset, 0px)); + padding-left: calc(1rem + var(--gm-mini-app-left-inset, 0px)); +} + +body.telegram-mini-app .content { + padding-bottom: calc(1.5rem + var(--gm-mini-app-bottom-inset, 0px)); +} + /* === Mobile Sessions Cards (instead of table) === */ .session-card-mobile { display: none; @@ -970,12 +1001,24 @@ select option { .telegram-mini-app .content { padding: 0.75rem; + padding-bottom: calc(0.75rem + var(--gm-mini-app-bottom-inset, 0px)); } .telegram-mini-app .page-container { padding: 0.75rem; } + body.telegram-mini-app .nav-header { + padding-top: calc(1.25rem + var(--gm-mini-app-top-inset, 0px)); + padding-left: calc(1rem + var(--gm-mini-app-left-inset, 0px)); + padding-right: calc(0.75rem + var(--gm-mini-app-right-inset, 0px)); + } + + body.telegram-mini-app .nav-toggle { + top: calc(0.75rem + var(--gm-mini-app-top-inset, 0px)); + left: calc(0.75rem + var(--gm-mini-app-left-inset, 0px)); + } + h2 { font-size: 1.25rem; } diff --git a/tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs b/tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs index 279d6f4..9510059 100644 --- a/tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/MiniAppDashboardTests.cs @@ -12,6 +12,10 @@ public sealed class MiniAppDashboardTests Assert.Contains("/auth/telegram-webapp", miniAppPage, StringComparison.Ordinal); Assert.Contains("watchTelegramMiniAppLogin", miniAppPage, StringComparison.Ordinal); Assert.Contains("/auth/status", miniAppPage, StringComparison.Ordinal); + Assert.Contains("miniAppAuthStatus", miniAppPage, StringComparison.Ordinal); + Assert.Contains("telegram-webapp-missing", miniAppPage, StringComparison.Ordinal); + Assert.Contains("telegram-init-data-empty", miniAppPage, StringComparison.Ordinal); + Assert.Contains("telegram-auth-failed", miniAppPage, StringComparison.Ordinal); } [Fact] @@ -24,10 +28,18 @@ public sealed class MiniAppDashboardTests Assert.Contains("Telegram.WebApp.initData", appShell, StringComparison.Ordinal); Assert.Contains("window.waitForTelegramMiniAppInitData", appShell, StringComparison.Ordinal); Assert.Contains("window.watchTelegramMiniAppLogin", appShell, StringComparison.Ordinal); + Assert.Contains("window.handleTelegramLogin", appShell, StringComparison.Ordinal); + Assert.Contains("/auth/telegram-login", appShell, StringComparison.Ordinal); + Assert.Contains("data-onauth", appShell, StringComparison.Ordinal); + Assert.DoesNotContain("data-auth-url", appShell, StringComparison.Ordinal); Assert.Contains("setTimeout(resolve, 100)", appShell, StringComparison.Ordinal); Assert.Contains("reloadOnReturn", appShell, StringComparison.Ordinal); Assert.Contains("gmRelayMiniAppLoginLeftPage", appShell, StringComparison.Ordinal); Assert.Contains("window.location.reload()", appShell, StringComparison.Ordinal); + Assert.Contains("syncTelegramMiniAppViewport", appShell, StringComparison.Ordinal); + Assert.Contains("safeAreaChanged", appShell, StringComparison.Ordinal); + Assert.Contains("contentSafeAreaChanged", appShell, StringComparison.Ordinal); + Assert.Contains("viewportChanged", appShell, StringComparison.Ordinal); } [Fact] @@ -36,7 +48,9 @@ public sealed class MiniAppDashboardTests var program = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Program.cs")); Assert.Contains("MapPost(\"/auth/telegram-webapp\"", program, StringComparison.Ordinal); + Assert.Contains("MapPost(\"/auth/telegram-login\"", program, StringComparison.Ordinal); Assert.Contains("VerifyWebAppInitData", program, StringComparison.Ordinal); + Assert.Contains("VerifyLoginPayload", program, StringComparison.Ordinal); Assert.Contains("MapGet(\"/auth/status\"", program, StringComparison.Ordinal); Assert.Contains("authenticated", program, StringComparison.Ordinal); } @@ -49,15 +63,24 @@ public sealed class MiniAppDashboardTests Assert.Contains("mini-app-page", css, StringComparison.Ordinal); Assert.Contains("mini-app-auth-card", css, StringComparison.Ordinal); Assert.Contains("@media (max-width: 768px)", css, StringComparison.Ordinal); + Assert.Contains("--tg-safe-area-inset-top", css, StringComparison.Ordinal); + Assert.Contains("--tg-content-safe-area-inset-top", css, StringComparison.Ordinal); + Assert.Contains(".telegram-mini-app .nav-header", css, StringComparison.Ordinal); + Assert.Contains(".telegram-mini-app .nav-toggle", css, StringComparison.Ordinal); + Assert.Contains(".telegram-mini-app .mini-app-page", css, StringComparison.Ordinal); } [Fact] - public async Task LoginPage_ShouldRefreshMiniAppAfterExternalTelegramLogin() + public async Task LoginPage_ShouldAuthenticateMiniAppFallbackInsideCurrentWebView() { var loginPage = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/Login.razor")); Assert.Contains( - "JS.InvokeVoidAsync(\"watchTelegramMiniAppLogin\", \"/auth/status\", \"/\", true)", + "JS.InvokeVoidAsync(\"loadTelegramWidget\", BotUsername, \"/auth/telegram-login\")", + loginPage, + StringComparison.Ordinal); + Assert.Contains( + "JS.InvokeVoidAsync(\"watchTelegramMiniAppLogin\", \"/auth/status\", \"/\", false)", loginPage, StringComparison.Ordinal); } diff --git a/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs index c1460b9..252caf5 100644 --- a/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/TelegramAuthServiceTests.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using System.Text.Json; using GmRelay.Web.Services; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -135,6 +136,125 @@ public sealed class TelegramAuthServiceTests Assert.False(verified); } + [Fact] + public void VerifyLoginPayload_ShouldAcceptValidTelegramWidgetCallbackPayload() + { + 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, + "Ada", + "Lovelace", + "ada", + "https://t.me/i/userpic/320/ada.jpg", + authDate, + ComputeTelegramHash(botToken, values)); + var service = new TelegramAuthService(CreateConfiguration(botToken)); + + var verified = service.VerifyLoginPayload(payload, out var telegramId, out var name); + + Assert.True(verified); + Assert.Equal(424242L, telegramId); + Assert.Equal("Ada Lovelace", name); + } + + [Fact] + public void VerifyLoginPayload_ShouldRejectTamperedCallbackHash() + { + var payload = new TelegramLoginPayload( + 424242, + "Ada", + null, + null, + null, + DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + "00"); + var service = new TelegramAuthService(CreateConfiguration("test-bot-token")); + + var verified = service.VerifyLoginPayload(payload, out _, out _); + + Assert.False(verified); + } + + [Fact] + public void VerifyLoginPayload_ShouldRejectExpiredCallbackPayload() + { + 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, + "Ada", + null, + null, + null, + authDate, + ComputeTelegramHash(botToken, values)); + var service = new TelegramAuthService(CreateConfiguration(botToken)); + + var verified = service.VerifyLoginPayload(payload, out _, out _); + + Assert.False(verified); + } + + [Fact] + public void VerifyLoginPayload_ShouldRejectMissingRequiredCallbackFields() + { + var payload = new TelegramLoginPayload( + 0, + "", + null, + null, + null, + DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ""); + var service = new TelegramAuthService(CreateConfiguration("test-bot-token")); + + var verified = service.VerifyLoginPayload(payload, out _, out _); + + Assert.False(verified); + } + + [Fact] + public void TelegramLoginPayload_ShouldDeserializeTelegramWidgetSnakeCaseJson() + { + var payload = JsonSerializer.Deserialize( + """ + { + "id": 424242, + "first_name": "Ada", + "last_name": "Lovelace", + "username": "ada", + "photo_url": "https://t.me/i/userpic/320/ada.jpg", + "auth_date": 1714300000, + "hash": "abcdef" + } + """); + + Assert.NotNull(payload); + Assert.Equal(424242L, payload.Id); + Assert.Equal("Ada", payload.FirstName); + Assert.Equal("Lovelace", payload.LastName); + Assert.Equal("ada", payload.Username); + Assert.Equal("https://t.me/i/userpic/320/ada.jpg", payload.PhotoUrl); + Assert.Equal(1714300000L, payload.AuthDate); + Assert.Equal("abcdef", payload.Hash); + } + private static IConfiguration CreateConfiguration(string botToken) => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary diff --git a/tests/GmRelay.Bot.Tests/Web/WebStylesTests.cs b/tests/GmRelay.Bot.Tests/Web/WebStylesTests.cs index 18d123d..ac29adf 100644 --- a/tests/GmRelay.Bot.Tests/Web/WebStylesTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/WebStylesTests.cs @@ -12,6 +12,19 @@ public sealed class WebStylesTests css); } + [Fact] + public async Task AppCss_ShouldReserveTelegramMiniAppSafeAreaForMobileChrome() + { + var appCss = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/wwwroot/app.css")); + Assert.Contains("--gm-tg-safe-top", appCss, StringComparison.Ordinal); + Assert.Contains("--tg-safe-area-inset-top", appCss, StringComparison.Ordinal); + Assert.Contains("--tg-content-safe-area-inset-top", appCss, StringComparison.Ordinal); + Assert.Contains("env(safe-area-inset-top", appCss, StringComparison.Ordinal); + Assert.Contains(".telegram-mini-app .content", appCss, StringComparison.Ordinal); + Assert.Contains(".telegram-mini-app .nav-header", appCss, StringComparison.Ordinal); + Assert.Contains(".telegram-mini-app .nav-toggle", appCss, StringComparison.Ordinal); + } + private static string FindRepositoryFile(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory);