From 1c4cfb71c0e697f1fe9bf8e7eba8e66866a5e66e Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 23 Apr 2026 20:09:22 +0300 Subject: [PATCH] fix: close web access to foreign groups and sessions --- src/GmRelay.Bot/GmRelay.Bot.csproj | 2 +- .../Components/Pages/AccessDenied.razor | 27 +++ .../Components/Pages/EditSession.razor | 42 +++-- .../Components/Pages/GroupDetails.razor | 18 +- src/GmRelay.Web/Components/Pages/Home.razor | 11 +- src/GmRelay.Web/Program.cs | 3 +- .../Services/AuthorizedSessionService.cs | 45 +++++ .../Services/ClaimsPrincipalExtensions.cs | 9 + src/GmRelay.Web/Services/ISessionStore.cs | 10 ++ .../Services/SessionAccessDeniedException.cs | 4 + src/GmRelay.Web/Services/SessionService.cs | 55 ++++-- .../GmRelay.Bot.Tests.csproj | 3 +- tests/GmRelay.Bot.Tests/UnitTest1.cs | 10 -- .../Web/AuthorizedSessionServiceTests.cs | 162 ++++++++++++++++++ 14 files changed, 352 insertions(+), 49 deletions(-) create mode 100644 src/GmRelay.Web/Components/Pages/AccessDenied.razor create mode 100644 src/GmRelay.Web/Services/AuthorizedSessionService.cs create mode 100644 src/GmRelay.Web/Services/ClaimsPrincipalExtensions.cs create mode 100644 src/GmRelay.Web/Services/ISessionStore.cs create mode 100644 src/GmRelay.Web/Services/SessionAccessDeniedException.cs delete mode 100644 tests/GmRelay.Bot.Tests/UnitTest1.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs diff --git a/src/GmRelay.Bot/GmRelay.Bot.csproj b/src/GmRelay.Bot/GmRelay.Bot.csproj index cfe9821..f73bf12 100644 --- a/src/GmRelay.Bot/GmRelay.Bot.csproj +++ b/src/GmRelay.Bot/GmRelay.Bot.csproj @@ -22,7 +22,7 @@ - + diff --git a/src/GmRelay.Web/Components/Pages/AccessDenied.razor b/src/GmRelay.Web/Components/Pages/AccessDenied.razor new file mode 100644 index 0000000..a5d1486 --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/AccessDenied.razor @@ -0,0 +1,27 @@ +@page "/access-denied" + +Доступ запрещен — GM-Relay + +
+
+
+
+
Доступ запрещен
+

Эта группа или сессия недоступна для вашей учётной записи.

+ ← На главную +
+
+
+ +@code { + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + protected override void OnInitialized() + { + if (HttpContext is not null && !HttpContext.Response.HasStarted) + { + HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden; + } + } +} diff --git a/src/GmRelay.Web/Components/Pages/EditSession.razor b/src/GmRelay.Web/Components/Pages/EditSession.razor index eab499b..aa100fd 100644 --- a/src/GmRelay.Web/Components/Pages/EditSession.razor +++ b/src/GmRelay.Web/Components/Pages/EditSession.razor @@ -2,8 +2,10 @@ @using GmRelay.Web.Services @using GmRelay.Shared.Domain @using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization @attribute [Authorize] -@inject SessionService SessionService +@inject AuthorizedSessionService SessionService +@inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation Редактирование сессии — GM-Relay @@ -73,19 +75,28 @@ [Parameter] public Guid SessionId { get; set; } private WebSession? session; private SessionEditModel model = new(); - private bool isSubmitting = false; + private bool isSubmitting; private string? errorMessage; protected override async Task OnInitializedAsync() { - session = await SessionService.GetSessionAsync(SessionId); - if (session != null) + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (!authState.User.TryGetTelegramId(out var telegramId)) { - model.Title = session.Title; - // Convert UTC to Moscow for the picker - model.ScheduledAtLocal = session.ScheduledAt.ToMoscow(); - model.JoinLink = session.JoinLink; + Navigation.NavigateTo("/access-denied"); + return; } + + session = await SessionService.GetSessionForGmAsync(SessionId, telegramId); + if (session is null) + { + Navigation.NavigateTo("/access-denied"); + return; + } + + model.Title = session.Title; + model.ScheduledAtLocal = session.ScheduledAt.ToMoscow(); + model.JoinLink = session.JoinLink; } private async Task HandleSubmit() @@ -95,13 +106,22 @@ try { - // The value from is considered as "unspecified" or local to browser. - // We treat it as Moscow time (UTC+3) and convert to UTC. + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (!authState.User.TryGetTelegramId(out var telegramId)) + { + Navigation.NavigateTo("/access-denied"); + return; + } + var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; - await SessionService.UpdateSessionAsync(SessionId, model.Title, utcTime, model.JoinLink); + await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink); Navigation.NavigateTo($"/group/{session!.GroupId}"); } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } catch (Exception ex) { errorMessage = "Не удалось сохранить изменения: " + ex.Message; diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index ec96c55..b3e9f02 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -2,8 +2,11 @@ @using GmRelay.Web.Services @using GmRelay.Shared.Domain @using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization @attribute [Authorize] -@inject SessionService SessionService +@inject AuthorizedSessionService SessionService +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation Сессии группы — GM-Relay @@ -112,7 +115,18 @@ protected override async Task OnInitializedAsync() { - sessions = await SessionService.GetUpcomingSessionsAsync(GroupId); + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (!authState.User.TryGetTelegramId(out var telegramId)) + { + Navigation.NavigateTo("/access-denied"); + return; + } + + sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); + if (sessions is null) + { + Navigation.NavigateTo("/access-denied"); + } } private string GetStatusClass(string status) => status switch diff --git a/src/GmRelay.Web/Components/Pages/Home.razor b/src/GmRelay.Web/Components/Pages/Home.razor index d6d06a7..869307d 100644 --- a/src/GmRelay.Web/Components/Pages/Home.razor +++ b/src/GmRelay.Web/Components/Pages/Home.razor @@ -3,8 +3,9 @@ @using Microsoft.AspNetCore.Components.Authorization @using GmRelay.Web.Services @attribute [Authorize] -@inject SessionService SessionService +@inject AuthorizedSessionService SessionService @inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation Панель управления — GM-Relay @@ -88,10 +89,12 @@ var user = authState.User; userName = user.Identity?.Name ?? "Мастер Игры"; - var telegramIdClaim = user.FindFirst("TelegramId")?.Value; - if (long.TryParse(telegramIdClaim, out var telegramId)) + if (!user.TryGetTelegramId(out var telegramId)) { - groups = await SessionService.GetGroupsForGmAsync(telegramId); + Navigation.NavigateTo("/access-denied"); + return; } + + groups = await SessionService.GetGroupsForGmAsync(telegramId); } } diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index c1f6333..324474b 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -21,7 +21,8 @@ builder.AddNpgsqlDataSource("gmrelaydb"); // Add Services builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Add Bot Client builder.Services.AddSingleton(sp => diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs new file mode 100644 index 0000000..310030b --- /dev/null +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -0,0 +1,45 @@ +namespace GmRelay.Web.Services; + +public sealed class AuthorizedSessionService(ISessionStore sessionStore) +{ + public Task> GetGroupsForGmAsync(long gmId) => + sessionStore.GetGroupsForGmAsync(gmId); + + public async Task?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId) + { + if (!await GroupBelongsToGmAsync(groupId, gmId)) + { + return null; + } + + return await sessionStore.GetUpcomingSessionsAsync(groupId); + } + + public async Task GetSessionForGmAsync(Guid sessionId, long gmId) + { + var session = await sessionStore.GetSessionAsync(sessionId); + if (session is null) + { + return null; + } + + return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null; + } + + public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink) + { + var session = await GetSessionForGmAsync(sessionId, gmId); + if (session is null) + { + throw new SessionAccessDeniedException(sessionId, gmId); + } + + await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink); + } + + private async Task GroupBelongsToGmAsync(Guid groupId, long gmId) + { + var group = await sessionStore.GetGroupAsync(groupId); + return group?.GmTelegramId == gmId; + } +} diff --git a/src/GmRelay.Web/Services/ClaimsPrincipalExtensions.cs b/src/GmRelay.Web/Services/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..c0427f9 --- /dev/null +++ b/src/GmRelay.Web/Services/ClaimsPrincipalExtensions.cs @@ -0,0 +1,9 @@ +using System.Security.Claims; + +namespace GmRelay.Web.Services; + +public static class ClaimsPrincipalExtensions +{ + public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) => + long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId); +} diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs new file mode 100644 index 0000000..0d7510c --- /dev/null +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -0,0 +1,10 @@ +namespace GmRelay.Web.Services; + +public interface ISessionStore +{ + Task> GetGroupsForGmAsync(long gmId); + Task GetGroupAsync(Guid groupId); + Task> GetUpcomingSessionsAsync(Guid groupId); + Task GetSessionAsync(Guid sessionId); + Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink); +} diff --git a/src/GmRelay.Web/Services/SessionAccessDeniedException.cs b/src/GmRelay.Web/Services/SessionAccessDeniedException.cs new file mode 100644 index 0000000..9b30b1a --- /dev/null +++ b/src/GmRelay.Web/Services/SessionAccessDeniedException.cs @@ -0,0 +1,4 @@ +namespace GmRelay.Web.Services; + +public sealed class SessionAccessDeniedException(Guid sessionId, long gmId) + : InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'."); diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index f8653d8..a5580cb 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -12,7 +12,7 @@ public sealed record WebSession(Guid Id, Guid GroupId, string Title, DateTime Sc public sealed class SessionService( NpgsqlDataSource dataSource, ITelegramBotClient bot, - ILogger logger) + ILogger logger) : ISessionStore { public async Task> GetGroupsForGmAsync(long gmId) { @@ -22,11 +22,19 @@ public sealed class SessionService( new { GmId = gmId })).ToList(); } + public async Task GetGroupAsync(Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId", + new { GroupId = groupId }); + } + public async Task> GetUpcomingSessionsAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( - @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, + @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId FROM sessions s @@ -40,7 +48,7 @@ public sealed class SessionService( { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( - @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, + @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId FROM sessions s @@ -49,29 +57,41 @@ public sealed class SessionService( new { SessionId = sessionId }); } - public async Task UpdateSessionAsync(Guid sessionId, string title, DateTime scheduledAt, string joinLink) + public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); - // 1. Fetch current session with all required columns for WebSession record - var oldSession = await conn.QuerySingleAsync( - @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, + var oldSession = await conn.QuerySingleOrDefaultAsync( + @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId FROM sessions s JOIN game_groups g ON g.id = s.group_id - WHERE s.id = @Id", - new { Id = sessionId }, transaction); - - // 2. Update Session - await conn.ExecuteAsync( - @"UPDATE sessions SET title = @Title, scheduled_at = @ScheduledAt, join_link = @JoinLink, updated_at = now() - WHERE id = @Id", - new { Id = sessionId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink }, + WHERE s.id = @Id AND s.group_id = @GroupId", + new { Id = sessionId, GroupId = groupId }, transaction); - // 3. Update all sessions in the same batch with new title (optional, usually batch shares title) + if (oldSession is null) + { + throw new SessionAccessDeniedException(sessionId, 0); + } + + var updatedRows = await conn.ExecuteAsync( + @"UPDATE sessions + SET title = @Title, + scheduled_at = @ScheduledAt, + join_link = @JoinLink, + updated_at = now() + WHERE id = @Id AND group_id = @GroupId", + new { Id = sessionId, GroupId = groupId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink }, + transaction); + + if (updatedRows == 0) + { + throw new SessionAccessDeniedException(sessionId, 0); + } + await conn.ExecuteAsync( "UPDATE sessions SET title = @Title WHERE batch_id = @BatchId", new { Title = title, BatchId = oldSession.BatchId }, @@ -79,7 +99,6 @@ public sealed class SessionService( await transaction.CommitAsync(); - // 4. Send Telegram Notification var timeChanged = oldSession.ScheduledAt != scheduledAt; var notification = $"🔄 Мастер обновил игру!\n\n" + $"📌 {System.Net.WebUtility.HtmlEncode(title)}\n" + @@ -87,7 +106,6 @@ public sealed class SessionService( await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); - // 5. Update Original Batch Message if (oldSession.BatchMessageId.HasValue) { await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title); @@ -124,7 +142,6 @@ public sealed class SessionService( } catch (Exception ex) { - // Log but don't throw — message may be too old or have same content logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId); } } diff --git a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj index bb1ef74..eabe801 100644 --- a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj +++ b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj @@ -20,6 +20,7 @@ + - \ No newline at end of file + diff --git a/tests/GmRelay.Bot.Tests/UnitTest1.cs b/tests/GmRelay.Bot.Tests/UnitTest1.cs deleted file mode 100644 index f1e7c7e..0000000 --- a/tests/GmRelay.Bot.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace GmRelay.Bot.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs new file mode 100644 index 0000000..91ea605 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -0,0 +1,162 @@ +using GmRelay.Web.Services; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class AuthorizedSessionServiceTests +{ + [Fact] + public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ], + sessions: + [ + new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) + ]); + var service = new AuthorizedSessionService(store); + + var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId); + + Assert.NotNull(sessions); + Assert.Single(sessions); + Assert.Equal("Session A", sessions[0].Title); + } + + [Fact] + public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm() + { + var groupId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", 2002L) + ]); + var service = new AuthorizedSessionService(store); + + var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L); + + Assert.Null(sessions); + } + + [Fact] + public async Task GetSessionForGmAsync_ReturnsSession_WhenSessionBelongsToOwnedGroup() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ], + sessions: + [ + new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) + ]); + var service = new AuthorizedSessionService(store); + + var session = await service.GetSessionForGmAsync(sessionId, gmId); + + Assert.NotNull(session); + Assert.Equal(sessionId, session.Id); + } + + [Fact] + public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm() + { + var groupId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", 2002L) + ], + sessions: + [ + new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) + ]); + var service = new AuthorizedSessionService(store); + + var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b"); + + await Assert.ThrowsAsync(action); + Assert.False(store.UpdateCalled); + } + + [Fact] + public async Task UpdateSessionForGmAsync_UpdatesOwnedSession() + { + var gmId = 1001L; + var groupId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var scheduledAt = DateTime.UtcNow.AddDays(1); + var store = new FakeSessionStore( + groups: + [ + new(groupId, 42, "Alpha", gmId) + ], + sessions: + [ + new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42) + ]); + var service = new AuthorizedSessionService(store); + + await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b"); + + Assert.True(store.UpdateCalled); + Assert.Equal(groupId, store.LastUpdatedGroupId); + Assert.Equal(sessionId, store.LastUpdatedSessionId); + Assert.Equal("Updated", store.LastUpdatedTitle); + Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt); + Assert.Equal("https://example.test/b", store.LastUpdatedJoinLink); + } + + private sealed class FakeSessionStore( + IEnumerable? groups = null, + IEnumerable? sessions = null) : ISessionStore + { + private readonly Dictionary groupsById = groups?.ToDictionary(group => group.Id) ?? []; + private readonly Dictionary sessionsById = sessions?.ToDictionary(session => session.Id) ?? []; + + public bool UpdateCalled { get; private set; } + public Guid? LastUpdatedSessionId { get; private set; } + public Guid? LastUpdatedGroupId { get; private set; } + public string? LastUpdatedTitle { get; private set; } + public DateTime? LastUpdatedScheduledAt { get; private set; } + public string? LastUpdatedJoinLink { get; private set; } + + public Task> GetGroupsForGmAsync(long gmId) => + Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList()); + + public Task GetGroupAsync(Guid groupId) + { + groupsById.TryGetValue(groupId, out var group); + return Task.FromResult(group); + } + + public Task> GetUpcomingSessionsAsync(Guid groupId) => + Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList()); + + public Task GetSessionAsync(Guid sessionId) + { + sessionsById.TryGetValue(sessionId, out var session); + return Task.FromResult(session); + } + + public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink) + { + UpdateCalled = true; + LastUpdatedSessionId = sessionId; + LastUpdatedGroupId = groupId; + LastUpdatedTitle = title; + LastUpdatedScheduledAt = scheduledAt; + LastUpdatedJoinLink = joinLink; + return Task.CompletedTask; + } + } +}