From c5e7479ee458278088ceb9a58b50b5bb5ed28489 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 07:10:32 -0400 Subject: [PATCH] Dashboard: admin-only Close session / Kill worker Add IDashboardSessionAdminService (Admin-role gate, friendly errors, audit logging) wrapping a new ISessionManager.KillWorkerAsync that skips graceful shutdown and cleans up registry/metrics. Sessions, Workers, and SessionDetails pages render Close / Kill buttons only when CanManage; the service re-checks the role on every call so forged clicks return Unauthenticated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Components/Pages/SessionDetailsPage.razor | 79 ++++++- .../Components/Pages/SessionsPage.razor | 83 +++++++ .../Components/Pages/WorkersPage.razor | 67 ++++++ .../DashboardServiceCollectionExtensions.cs | 1 + .../Dashboard/DashboardSessionAdminResult.cs | 16 ++ .../Dashboard/DashboardSessionAdminService.cs | 136 +++++++++++ .../IDashboardSessionAdminService.cs | 18 ++ .../Sessions/ISessionManager.cs | 13 + .../Sessions/SessionManager.cs | 50 ++++ .../DashboardSessionAdminServiceTests.cs | 223 ++++++++++++++++++ .../Gateway/Grpc/EventStreamServiceTests.cs | 9 + .../MxAccessGatewayServiceConstraintTests.cs | 6 + .../Grpc/MxAccessGatewayServiceTests.cs | 9 + .../Gateway/Sessions/SessionManagerTests.cs | 32 +++ ...atewayGrpcAuthorizationInterceptorTests.cs | 9 + 15 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAdminService.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor index 4a0814d..1c5616e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor @@ -4,6 +4,8 @@ @using Microsoft.AspNetCore.SignalR.Client @using ZB.MOM.WW.MxGateway.Contracts.Proto @using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject IDashboardSessionAdminService SessionAdminService Dashboard Session @@ -25,9 +27,33 @@ else

Session Details

@CurrentSession.SessionId
- +
+ + @if (CanManage) + { +
+ + +
+ } +
+ @if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage)) + { + + } +

Session

@@ -124,6 +150,23 @@ else private string? _subscribedSessionId; private readonly LinkedList _recentEvents = new(); + private bool CanManage { get; set; } + + private bool IsBusy { get; set; } + + private string? ResultMessage { get; set; } + + private bool LastOperationSucceeded { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(false); + + AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync() + .ConfigureAwait(false); + CanManage = SessionAdminService.CanManage(authenticationState.User); + } + protected override async Task OnParametersSetAsync() { if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal)) @@ -133,6 +176,40 @@ else } } + private Task CloseSessionAsync() + { + return RunAdminActionAsync(user => SessionAdminService.CloseSessionAsync(user, SessionId, CancellationToken.None)); + } + + private Task KillWorkerAsync() + { + return RunAdminActionAsync(user => SessionAdminService.KillWorkerAsync(user, SessionId, CancellationToken.None)); + } + + private async Task RunAdminActionAsync( + Func> action) + { + if (IsBusy) + { + return; + } + + IsBusy = true; + try + { + AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync() + .ConfigureAwait(false); + CanManage = SessionAdminService.CanManage(authenticationState.User); + DashboardSessionAdminResult result = await action(authenticationState.User).ConfigureAwait(false); + ResultMessage = result.Message; + LastOperationSucceeded = result.Succeeded; + } + finally + { + IsBusy = false; + } + } + private async Task AttachEventsHubAsync() { if (string.IsNullOrWhiteSpace(SessionId)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor index e8df4dc..c4d801c 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor @@ -1,5 +1,7 @@ @page "/sessions" @inherits DashboardPageBase +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject IDashboardSessionAdminService SessionAdminService Dashboard Sessions @@ -16,6 +18,13 @@ else
+ @if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage)) + { + + } +
@if (Snapshot.Sessions.Count == 0) { @@ -37,6 +46,10 @@ else Activity Heartbeat Fault + @if (CanManage) + { + Actions + } @@ -59,6 +72,23 @@ else @DashboardDisplay.DateTime(session.LastClientActivityAt) @DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt) @DashboardDisplay.Text(session.LastFault) + @if (CanManage) + { + +
+ + +
+ + } } @@ -67,3 +97,56 @@ else }
} + +@code { + private bool CanManage { get; set; } + + private bool IsBusy { get; set; } + + private string? ResultMessage { get; set; } + + private bool LastOperationSucceeded { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(false); + + AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync() + .ConfigureAwait(false); + CanManage = SessionAdminService.CanManage(authenticationState.User); + } + + private Task CloseSessionAsync(string sessionId) + { + return RunActionAsync(user => SessionAdminService.CloseSessionAsync(user, sessionId, CancellationToken.None)); + } + + private Task KillWorkerAsync(string sessionId) + { + return RunActionAsync(user => SessionAdminService.KillWorkerAsync(user, sessionId, CancellationToken.None)); + } + + private async Task RunActionAsync( + Func> action) + { + if (IsBusy) + { + return; + } + + IsBusy = true; + try + { + AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync() + .ConfigureAwait(false); + CanManage = SessionAdminService.CanManage(authenticationState.User); + DashboardSessionAdminResult result = await action(authenticationState.User).ConfigureAwait(false); + ResultMessage = result.Message; + LastOperationSucceeded = result.Succeeded; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor index 4811852..5fd3edb 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor @@ -1,5 +1,7 @@ @page "/workers" @inherits DashboardPageBase +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject IDashboardSessionAdminService SessionAdminService Dashboard Workers @@ -16,6 +18,13 @@ else + @if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage)) + { + + } +
@if (Snapshot.Workers.Count == 0) { @@ -32,6 +41,10 @@ else Session Heartbeat Fault + @if (CanManage) + { + Actions + } @@ -43,6 +56,16 @@ else @worker.SessionId @DashboardDisplay.DateTime(worker.LastHeartbeatAt) @DashboardDisplay.Text(worker.LastFault) + @if (CanManage) + { + + + + } } @@ -51,3 +74,47 @@ else }
} + +@code { + private bool CanManage { get; set; } + + private bool IsBusy { get; set; } + + private string? ResultMessage { get; set; } + + private bool LastOperationSucceeded { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(false); + + AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync() + .ConfigureAwait(false); + CanManage = SessionAdminService.CanManage(authenticationState.User); + } + + private async Task KillWorkerAsync(string sessionId) + { + if (IsBusy) + { + return; + } + + IsBusy = true; + try + { + AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync() + .ConfigureAwait(false); + CanManage = SessionAdminService.CanManage(authenticationState.User); + DashboardSessionAdminResult result = await SessionAdminService + .KillWorkerAsync(authenticationState.User, sessionId, CancellationToken.None) + .ConfigureAwait(false); + ResultMessage = result.Message; + LastOperationSucceeded = result.Succeeded; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index 1382c11..7380b97 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -20,6 +20,7 @@ public static class DashboardServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddSingleton(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs new file mode 100644 index 0000000..ed7f05a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs @@ -0,0 +1,16 @@ +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public sealed record DashboardSessionAdminResult( + bool Succeeded, + string Message) +{ + public static DashboardSessionAdminResult Success(string message) + { + return new DashboardSessionAdminResult(true, message); + } + + public static DashboardSessionAdminResult Fail(string message) + { + return new DashboardSessionAdminResult(false, message); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs new file mode 100644 index 0000000..7fc6c80 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs @@ -0,0 +1,136 @@ +using System.Security.Claims; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public sealed class DashboardSessionAdminService( + ISessionManager sessionManager, + IHttpContextAccessor httpContextAccessor, + ILogger? logger = null) : IDashboardSessionAdminService +{ + private const string UnauthorizedMessage = "Sign in as an Admin to close sessions or kill workers."; + private const string KillReason = "dashboard-admin-kill"; + + private readonly ILogger _logger = + logger ?? NullLogger.Instance; + + public bool CanManage(ClaimsPrincipal user) + { + ArgumentNullException.ThrowIfNull(user); + + return user.Identity?.IsAuthenticated == true + && user.IsInRole(DashboardRoles.Admin); + } + + public async Task CloseSessionAsync( + ClaimsPrincipal user, + string sessionId, + CancellationToken cancellationToken) + { + if (!CanManage(user)) + { + return DashboardSessionAdminResult.Fail(UnauthorizedMessage); + } + + if (string.IsNullOrWhiteSpace(sessionId)) + { + return DashboardSessionAdminResult.Fail("Session id is required."); + } + + string actor = ResolveActor(user); + try + { + SessionCloseResult result = await sessionManager + .CloseSessionAsync(sessionId, cancellationToken) + .ConfigureAwait(false); + + _logger.LogInformation( + "Dashboard admin {Actor} closed session {SessionId} from {RemoteAddress}; alreadyClosed={AlreadyClosed}.", + actor, + sessionId, + ResolveRemoteAddress(), + result.AlreadyClosed); + + return DashboardSessionAdminResult.Success( + result.AlreadyClosed + ? $"Session {sessionId} was already closed." + : $"Session {sessionId} closed."); + } + catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound) + { + return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found."); + } + catch (SessionManagerException exception) + { + _logger.LogWarning( + exception, + "Dashboard admin {Actor} close failed for session {SessionId}.", + actor, + sessionId); + return DashboardSessionAdminResult.Fail( + $"Close failed: {exception.Message}"); + } + } + + public async Task KillWorkerAsync( + ClaimsPrincipal user, + string sessionId, + CancellationToken cancellationToken) + { + if (!CanManage(user)) + { + return DashboardSessionAdminResult.Fail(UnauthorizedMessage); + } + + if (string.IsNullOrWhiteSpace(sessionId)) + { + return DashboardSessionAdminResult.Fail("Session id is required."); + } + + string actor = ResolveActor(user); + try + { + SessionCloseResult result = await sessionManager + .KillWorkerAsync(sessionId, KillReason, cancellationToken) + .ConfigureAwait(false); + + _logger.LogInformation( + "Dashboard admin {Actor} killed worker for session {SessionId} from {RemoteAddress}; alreadyClosed={AlreadyClosed}.", + actor, + sessionId, + ResolveRemoteAddress(), + result.AlreadyClosed); + + return DashboardSessionAdminResult.Success( + result.AlreadyClosed + ? $"Session {sessionId} was already closed." + : $"Worker for session {sessionId} killed."); + } + catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound) + { + return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found."); + } + catch (SessionManagerException exception) + { + _logger.LogWarning( + exception, + "Dashboard admin {Actor} kill failed for session {SessionId}.", + actor, + sessionId); + return DashboardSessionAdminResult.Fail( + $"Kill failed: {exception.Message}"); + } + } + + private static string ResolveActor(ClaimsPrincipal user) + { + return user.Identity?.Name ?? ""; + } + + private string? ResolveRemoteAddress() + { + return httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAdminService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAdminService.cs new file mode 100644 index 0000000..d313609 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAdminService.cs @@ -0,0 +1,18 @@ +using System.Security.Claims; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +public interface IDashboardSessionAdminService +{ + bool CanManage(ClaimsPrincipal user); + + Task CloseSessionAsync( + ClaimsPrincipal user, + string sessionId, + CancellationToken cancellationToken); + + Task KillWorkerAsync( + ClaimsPrincipal user, + string sessionId, + CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs index 0c137ee..2d84f26 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs @@ -49,6 +49,19 @@ public interface ISessionManager string sessionId, CancellationToken cancellationToken); + /// + /// Forcefully terminates a session's worker without attempting graceful shutdown, + /// transitions the session to Closed, and removes it from the registry. + /// + /// Identifier of the session whose worker to kill. + /// Reason for killing the worker (recorded in logs/audit). + /// Token to cancel the asynchronous operation. + /// The result of closing the session. + Task KillWorkerAsync( + string sessionId, + string reason, + CancellationToken cancellationToken); + /// Closes all sessions with expired leases at the specified time. /// The current time to evaluate expiration against. /// Token to cancel the asynchronous operation. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs index a648762..68807f2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs @@ -203,6 +203,56 @@ public sealed class SessionManager : ISessionManager return result; } + /// + /// Forcefully terminates a session's worker without attempting graceful shutdown. + /// Mirrors the registry/metrics cleanup that + /// performs after a successful close, but skips the WorkerClient.ShutdownAsync + /// step that would otherwise attempt. + /// + /// Session identifier. + /// Reason recorded for the kill. + /// Cancellation token. + /// Session close result. + public async Task KillWorkerAsync( + string sessionId, + string reason, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + cancellationToken.ThrowIfCancellationRequested(); + + GatewaySession session = GetRequiredSession(sessionId); + bool wasClosed = session.State == SessionState.Closed; + + try + { + session.KillWorker(reason); + } + catch (Exception exception) + { + session.MarkFaulted(exception.Message); + _metrics.Fault(SessionManagerErrorCode.CloseFailed.ToString()); + await RemoveSessionAsync(session).ConfigureAwait(false); + throw new SessionManagerException( + SessionManagerErrorCode.CloseFailed, + $"Failed to kill worker for session {sessionId}.", + exception); + } + + if (!wasClosed) + { + _metrics.SessionClosed(); + } + + await RemoveSessionAsync(session).ConfigureAwait(false); + _logger.LogInformation( + "Worker for session {SessionId} killed; reason={Reason}.", + sessionId, + reason); + + return new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: wasClosed); + } + /// /// Closes all sessions with expired leases asynchronously. /// diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs new file mode 100644 index 0000000..74dc162 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs @@ -0,0 +1,223 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardSessionAdminServiceTests +{ + [Fact] + public async Task CloseSessionAsync_ViewerCannotManage() + { + FakeSessionManager sessionManager = new(); + DashboardSessionAdminService service = CreateService(sessionManager); + + DashboardSessionAdminResult result = await service.CloseSessionAsync( + CreateUser(DashboardRoles.Viewer), + "session-1", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(0, sessionManager.CloseCount); + } + + [Fact] + public async Task CloseSessionAsync_AdminClosesSession() + { + FakeSessionManager sessionManager = new(); + DashboardSessionAdminService service = CreateService(sessionManager); + + DashboardSessionAdminResult result = await service.CloseSessionAsync( + CreateUser(DashboardRoles.Admin), + "session-1", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal(1, sessionManager.CloseCount); + Assert.Equal("session-1", sessionManager.LastClosedSessionId); + } + + [Fact] + public async Task CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError() + { + FakeSessionManager sessionManager = new() + { + CloseThrowsNotFound = true, + }; + DashboardSessionAdminService service = CreateService(sessionManager); + + DashboardSessionAdminResult result = await service.CloseSessionAsync( + CreateUser(DashboardRoles.Admin), + "session-missing", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task KillWorkerAsync_ViewerCannotManage() + { + FakeSessionManager sessionManager = new(); + DashboardSessionAdminService service = CreateService(sessionManager); + + DashboardSessionAdminResult result = await service.KillWorkerAsync( + CreateUser(DashboardRoles.Viewer), + "session-1", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(0, sessionManager.KillCount); + } + + [Fact] + public async Task KillWorkerAsync_AdminKillsWorker() + { + FakeSessionManager sessionManager = new(); + DashboardSessionAdminService service = CreateService(sessionManager); + + DashboardSessionAdminResult result = await service.KillWorkerAsync( + CreateUser(DashboardRoles.Admin), + "session-1", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal(1, sessionManager.KillCount); + Assert.Equal("session-1", sessionManager.LastKilledSessionId); + Assert.False(string.IsNullOrWhiteSpace(sessionManager.LastKillReason)); + } + + [Fact] + public async Task KillWorkerAsync_BlankSessionId_ReturnsFailure() + { + FakeSessionManager sessionManager = new(); + DashboardSessionAdminService service = CreateService(sessionManager); + + DashboardSessionAdminResult result = await service.KillWorkerAsync( + CreateUser(DashboardRoles.Admin), + " ", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(0, sessionManager.KillCount); + } + + [Fact] + public void CanManage_RejectsUnauthenticatedAndViewer() + { + DashboardSessionAdminService service = CreateService(new FakeSessionManager()); + + Assert.False(service.CanManage(new ClaimsPrincipal(new ClaimsIdentity()))); + Assert.False(service.CanManage(CreateUser(DashboardRoles.Viewer))); + Assert.True(service.CanManage(CreateUser(DashboardRoles.Admin))); + } + + private static DashboardSessionAdminService CreateService(ISessionManager sessionManager) + { + DefaultHttpContext httpContext = new(); + httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; + + return new DashboardSessionAdminService( + sessionManager, + new HttpContextAccessor { HttpContext = httpContext }); + } + + private static ClaimsPrincipal CreateUser(string role) + { + ClaimsIdentity identity = new( + [new Claim(ClaimTypes.Name, "tester"), new Claim(ClaimTypes.Role, role)], + DashboardAuthenticationDefaults.AuthenticationScheme, + ClaimTypes.Name, + ClaimTypes.Role); + + return new ClaimsPrincipal(identity); + } + + private sealed class FakeSessionManager : ISessionManager + { + public int CloseCount { get; private set; } + + public int KillCount { get; private set; } + + public string? LastClosedSessionId { get; private set; } + + public string? LastKilledSessionId { get; private set; } + + public string? LastKillReason { get; private set; } + + public bool CloseThrowsNotFound { get; init; } + + public Task OpenSessionAsync( + SessionOpenRequest request, + string? clientIdentity, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public bool TryGetSession( + string sessionId, + [MaybeNullWhen(false)] out GatewaySession session) + { + session = null; + return false; + } + + public Task InvokeAsync( + string sessionId, + WorkerCommand command, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public IAsyncEnumerable ReadEventsAsync( + string sessionId, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public Task CloseSessionAsync( + string sessionId, + CancellationToken cancellationToken) + { + CloseCount++; + LastClosedSessionId = sessionId; + if (CloseThrowsNotFound) + { + throw new SessionManagerException( + SessionManagerErrorCode.SessionNotFound, + $"Session {sessionId} was not found."); + } + + return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + } + + public Task KillWorkerAsync( + string sessionId, + string reason, + CancellationToken cancellationToken) + { + KillCount++; + LastKilledSessionId = sessionId; + LastKillReason = reason; + return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + } + + public Task CloseExpiredLeasesAsync( + DateTimeOffset now, + CancellationToken cancellationToken) + { + return Task.FromResult(0); + } + + public Task ShutdownAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs index fc11e9c..990195a 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs @@ -505,6 +505,15 @@ public sealed class EventStreamServiceTests return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } + /// + public Task KillWorkerAsync( + string sessionId, + string reason, + CancellationToken cancellationToken) + { + return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + } + /// public Task CloseExpiredLeasesAsync( DateTimeOffset now, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs index 6b84b4d..9e99dd3 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs @@ -898,6 +898,12 @@ public sealed class MxAccessGatewayServiceConstraintTests CancellationToken cancellationToken) => Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + public Task KillWorkerAsync( + string sessionId, + string reason, + CancellationToken cancellationToken) => + Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + public Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult(0); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs index 3aec895..3b4bcd5 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs @@ -539,6 +539,15 @@ public sealed class MxAccessGatewayServiceTests return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } + /// + public Task KillWorkerAsync( + string sessionId, + string reason, + CancellationToken cancellationToken) + { + return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + } + /// public Task CloseExpiredLeasesAsync( DateTimeOffset now, diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs index 5c64db7..7fea357 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs @@ -463,6 +463,38 @@ public sealed class SessionManagerTests Assert.Equal(0, metrics.GetSnapshot().OpenSessions); } + /// Verifies that killing a worker removes the session from the registry without calling shutdown. + [Fact] + public async Task KillWorkerAsync_KillsWorkerAndRemovesSession() + { + FakeWorkerClient workerClient = new(); + using GatewayMetrics metrics = new(); + SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient), metrics: metrics); + GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None); + + SessionCloseResult result = await manager.KillWorkerAsync(session.SessionId, "test-kill", CancellationToken.None); + + Assert.False(result.AlreadyClosed); + Assert.Equal(SessionState.Closed, result.FinalState); + Assert.Equal(1, workerClient.KillCount); + Assert.Equal(0, workerClient.ShutdownCount); + Assert.False(manager.TryGetSession(session.SessionId, out _)); + Assert.Equal(1, metrics.GetSnapshot().SessionsClosed); + Assert.Equal(0, metrics.GetSnapshot().OpenSessions); + } + + /// Verifies that killing the worker for an unknown session raises SessionNotFound. + [Fact] + public async Task KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound() + { + SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient())); + + SessionManagerException exception = await Assert.ThrowsAsync( + async () => await manager.KillWorkerAsync("session-missing", "test-kill", CancellationToken.None)); + + Assert.Equal(SessionManagerErrorCode.SessionNotFound, exception.ErrorCode); + } + /// Verifies that when worker creation fails, the session is removed from the registry. [Fact] public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry() diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs index e7cc3c5..c171b3a 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs @@ -458,6 +458,15 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } + /// + public Task KillWorkerAsync( + string sessionId, + string reason, + CancellationToken cancellationToken) + { + return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + } + /// public Task CloseExpiredLeasesAsync( DateTimeOffset now,