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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
<PageTitle>Dashboard Session</PageTitle>
|
||||
|
||||
@@ -25,9 +27,33 @@ else
|
||||
<h1>Session Details</h1>
|
||||
<div class="text-secondary"><code>@CurrentSession.SessionId</code></div>
|
||||
</div>
|
||||
<StatusBadge Text="@CurrentSession.State.ToString()" />
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<StatusBadge Text="@CurrentSession.State.ToString()" />
|
||||
@if (CanManage)
|
||||
{
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Session admin actions">
|
||||
<button type="button" class="btn btn-outline-warning"
|
||||
disabled="@IsBusy"
|
||||
@onclick="CloseSessionAsync">
|
||||
Close session
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
disabled="@IsBusy"
|
||||
@onclick="KillWorkerAsync">
|
||||
Kill worker
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage))
|
||||
{
|
||||
<div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
|
||||
@ResultMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Session</h2>
|
||||
@@ -124,6 +150,23 @@ else
|
||||
private string? _subscribedSessionId;
|
||||
private readonly LinkedList<MxEvent> _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<System.Security.Claims.ClaimsPrincipal, Task<DashboardSessionAdminResult>> 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))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@page "/sessions"
|
||||
@inherits DashboardPageBase
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject IDashboardSessionAdminService SessionAdminService
|
||||
|
||||
<PageTitle>Dashboard Sessions</PageTitle>
|
||||
|
||||
@@ -16,6 +18,13 @@ else
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage))
|
||||
{
|
||||
<div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
|
||||
@ResultMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="dashboard-section">
|
||||
@if (Snapshot.Sessions.Count == 0)
|
||||
{
|
||||
@@ -37,6 +46,10 @@ else
|
||||
<th scope="col">Activity</th>
|
||||
<th scope="col">Heartbeat</th>
|
||||
<th scope="col">Fault</th>
|
||||
@if (CanManage)
|
||||
{
|
||||
<th scope="col">Actions</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -59,6 +72,23 @@ else
|
||||
<td>@DashboardDisplay.DateTime(session.LastClientActivityAt)</td>
|
||||
<td>@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt)</td>
|
||||
<td>@DashboardDisplay.Text(session.LastFault)</td>
|
||||
@if (CanManage)
|
||||
{
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Session actions">
|
||||
<button type="button" class="btn btn-outline-warning"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => CloseSessionAsync(session.SessionId)">
|
||||
Close
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => KillWorkerAsync(session.SessionId)">
|
||||
Kill
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -67,3 +97,56 @@ else
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@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<System.Security.Claims.ClaimsPrincipal, Task<DashboardSessionAdminResult>> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@page "/workers"
|
||||
@inherits DashboardPageBase
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject IDashboardSessionAdminService SessionAdminService
|
||||
|
||||
<PageTitle>Dashboard Workers</PageTitle>
|
||||
|
||||
@@ -16,6 +18,13 @@ else
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage))
|
||||
{
|
||||
<div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
|
||||
@ResultMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="dashboard-section">
|
||||
@if (Snapshot.Workers.Count == 0)
|
||||
{
|
||||
@@ -32,6 +41,10 @@ else
|
||||
<th scope="col">Session</th>
|
||||
<th scope="col">Heartbeat</th>
|
||||
<th scope="col">Fault</th>
|
||||
@if (CanManage)
|
||||
{
|
||||
<th scope="col">Actions</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -43,6 +56,16 @@ else
|
||||
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
|
||||
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
|
||||
<td>@DashboardDisplay.Text(worker.LastFault)</td>
|
||||
@if (CanManage)
|
||||
{
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => KillWorkerAsync(worker.SessionId)">
|
||||
Kill
|
||||
</button>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -51,3 +74,47 @@ else
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public static class DashboardServiceCollectionExtensions
|
||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
||||
services.AddSingleton<HubTokenService>();
|
||||
services.AddScoped<Hubs.DashboardHubConnectionFactory>();
|
||||
services.AddSingleton<Hubs.IDashboardEventBroadcaster, Hubs.DashboardEventBroadcaster>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<DashboardSessionAdminService>? 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<DashboardSessionAdminService> _logger =
|
||||
logger ?? NullLogger<DashboardSessionAdminService>.Instance;
|
||||
|
||||
public bool CanManage(ClaimsPrincipal user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
return user.Identity?.IsAuthenticated == true
|
||||
&& user.IsInRole(DashboardRoles.Admin);
|
||||
}
|
||||
|
||||
public async Task<DashboardSessionAdminResult> 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<DashboardSessionAdminResult> 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 ?? "<unknown>";
|
||||
}
|
||||
|
||||
private string? ResolveRemoteAddress()
|
||||
{
|
||||
return httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public interface IDashboardSessionAdminService
|
||||
{
|
||||
bool CanManage(ClaimsPrincipal user);
|
||||
|
||||
Task<DashboardSessionAdminResult> CloseSessionAsync(
|
||||
ClaimsPrincipal user,
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardSessionAdminResult> KillWorkerAsync(
|
||||
ClaimsPrincipal user,
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -49,6 +49,19 @@ public interface ISessionManager
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Forcefully terminates a session's worker without attempting graceful shutdown,
|
||||
/// transitions the session to Closed, and removes it from the registry.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Identifier of the session whose worker to kill.</param>
|
||||
/// <param name="reason">Reason for killing the worker (recorded in logs/audit).</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>The result of closing the session.</returns>
|
||||
Task<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Closes all sessions with expired leases at the specified time.</summary>
|
||||
/// <param name="now">The current time to evaluate expiration against.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
|
||||
@@ -203,6 +203,56 @@ public sealed class SessionManager : ISessionManager
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forcefully terminates a session's worker without attempting graceful shutdown.
|
||||
/// Mirrors the registry/metrics cleanup that <see cref="CloseSessionCoreAsync"/>
|
||||
/// performs after a successful close, but skips the <c>WorkerClient.ShutdownAsync</c>
|
||||
/// step that <see cref="GatewaySession.CloseAsync"/> would otherwise attempt.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Session identifier.</param>
|
||||
/// <param name="reason">Reason recorded for the kill.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Session close result.</returns>
|
||||
public async Task<SessionCloseResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes all sessions with expired leases asynchronously.
|
||||
/// </summary>
|
||||
|
||||
@@ -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<GatewaySession> 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<WorkerCommandReply> InvokeAsync(
|
||||
string sessionId,
|
||||
WorkerCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Task<SessionCloseResult> 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<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
KillCount++;
|
||||
LastKilledSessionId = sessionId;
|
||||
LastKillReason = reason;
|
||||
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
}
|
||||
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,6 +505,15 @@ public sealed class EventStreamServiceTests
|
||||
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
|
||||
@@ -898,6 +898,12 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
public Task<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken) => Task.FromResult(0);
|
||||
|
||||
@@ -539,6 +539,15 @@ public sealed class MxAccessGatewayServiceTests
|
||||
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
|
||||
@@ -463,6 +463,38 @@ public sealed class SessionManagerTests
|
||||
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that killing a worker removes the session from the registry without calling shutdown.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that killing the worker for an unknown session raises SessionNotFound.</summary>
|
||||
[Fact]
|
||||
public async Task KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound()
|
||||
{
|
||||
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient()));
|
||||
|
||||
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
|
||||
async () => await manager.KillWorkerAsync("session-missing", "test-kill", CancellationToken.None));
|
||||
|
||||
Assert.Equal(SessionManagerErrorCode.SessionNotFound, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when worker creation fails, the session is removed from the registry.</summary>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
|
||||
|
||||
+9
@@ -458,6 +458,15 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
|
||||
Reference in New Issue
Block a user