fa9eb0c0b4
ISessionManager.ReadEventsAsync had zero production call sites: the worker event channel is drained once by GatewaySession.MapWorkerEventsAsync (the distributor pump), and every consumer — gRPC subscribers, the dashboard mirror, the alarm monitor — attaches to the distributor. The interface member, SessionManager's forwarder, and GatewaySession.ReadEventsAsync are gone; IWorkerClient/WorkerClient.ReadEventsAsync is untouched, it is the live worker-channel claim. No test was removed or rewired: nothing invoked the member through the interface. Nine ISessionManager test fakes carried a required-member stub (seven threw NotSupportedException or yielded nothing; EventStreamServiceTests and GatewaySessionDashboardMirrorTests forwarded to the session; the two MxAccessGatewayService fakes yielded their Events list) — all nine stubs were deleted. The MxAccessGatewayService suites' streaming tests already run through FakeEventStreamService, which reads the same Events list, so their coverage is unchanged; only the now-inaccurate doc comments on Events / LastReadEventsSessionId were reworded. The MapWorkerEventsAsync comment no longer describes a twin to keep in step; it now states the single-reader claim directly. docs/Sessions.md drops ReadEventsAsync from the SessionManager member list and from the Run-state prose. The 2026-08-15 deferred-remediation as-built note records the removal.
427 lines
16 KiB
C#
427 lines
16 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http;
|
|
using ZB.MOM.WW.Audit;
|
|
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
|
|
{
|
|
/// <summary>Verifies that a viewer cannot close a session.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an admin can close a session.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that closing a missing session returns a friendly error message.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a viewer cannot kill a worker.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an admin can kill a worker.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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.Equal("dashboard-admin-kill", sessionManager.LastKillReason);
|
|
}
|
|
|
|
/// <summary>Verifies that killing a worker with a blank session ID returns failure.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>CloseSessionAsync</c> has the same blank-session-id guard as
|
|
/// <c>KillWorkerAsync</c> but previously had no parallel test. Coverage was asymmetric.
|
|
/// A guard-removal regression on the close path would slip through.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CloseSessionAsync_BlankSessionId_ReturnsFailure()
|
|
{
|
|
FakeSessionManager sessionManager = new();
|
|
DashboardSessionAdminService service = CreateService(sessionManager);
|
|
|
|
DashboardSessionAdminResult result = await service.CloseSessionAsync(
|
|
CreateUser(DashboardRoles.Admin),
|
|
" ",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(0, sessionManager.CloseCount);
|
|
}
|
|
|
|
/// <summary>Verifies that CanManage rejects unauthenticated users and viewers.</summary>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression: an unexpected (non-<see cref="SessionManagerException"/>)
|
|
/// exception from <c>CloseSessionAsync</c> — e.g. an <see cref="InvalidOperationException"/>
|
|
/// or <see cref="IOException"/> surfaced from <c>RemoveSessionAsync</c>/<c>DisposeAsync</c> —
|
|
/// must be converted to a friendly <see cref="DashboardSessionAdminResult.Fail(string)"/>
|
|
/// rather than propagating raw into Blazor's error boundary.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CloseSessionAsync_WhenManagerThrowsUnexpected_ReturnsFriendlyFail()
|
|
{
|
|
FakeSessionManager sessionManager = new()
|
|
{
|
|
CloseThrowsUnexpected = new InvalidOperationException("unexpected"),
|
|
};
|
|
DashboardSessionAdminService service = CreateService(sessionManager);
|
|
|
|
DashboardSessionAdminResult result = await service.CloseSessionAsync(
|
|
CreateUser(DashboardRoles.Admin),
|
|
"session-1",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.False(string.IsNullOrWhiteSpace(result.Message));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression: same friendly-fail contract for the Kill path.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task KillWorkerAsync_WhenManagerThrowsUnexpected_ReturnsFriendlyFail()
|
|
{
|
|
FakeSessionManager sessionManager = new()
|
|
{
|
|
KillThrowsUnexpected = new IOException("pipe broken"),
|
|
};
|
|
DashboardSessionAdminService service = CreateService(sessionManager);
|
|
|
|
DashboardSessionAdminResult result = await service.KillWorkerAsync(
|
|
CreateUser(DashboardRoles.Admin),
|
|
"session-1",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.False(string.IsNullOrWhiteSpace(result.Message));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a successful close writes a canonical <c>dashboard-close-session</c>
|
|
/// <see cref="AuditEvent"/> — actor, session-id target, and Success outcome — to the
|
|
/// audit store, not only the operational log.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CloseSessionAsync_AdminClose_WritesCanonicalAuditEvent()
|
|
{
|
|
FakeSessionManager sessionManager = new();
|
|
RecordingAuditWriter auditWriter = new();
|
|
DashboardSessionAdminService service = CreateService(sessionManager, auditWriter);
|
|
|
|
DashboardSessionAdminResult result = await service.CloseSessionAsync(
|
|
CreateUser(DashboardRoles.Admin),
|
|
"session-1",
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
AuditEvent audit = Assert.Single(auditWriter.Events);
|
|
Assert.Equal(DashboardSessionAdminService.CloseSessionAction, audit.Action);
|
|
Assert.Equal(DashboardSessionAdminService.SessionAdminCategory, audit.Category);
|
|
Assert.Equal("session-1", audit.Target);
|
|
Assert.Equal("tester", audit.Actor);
|
|
Assert.Equal(AuditOutcome.Success, audit.Outcome);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a successful kill writes a canonical <c>dashboard-kill-worker</c>
|
|
/// <see cref="AuditEvent"/> to the audit store.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task KillWorkerAsync_AdminKill_WritesCanonicalAuditEvent()
|
|
{
|
|
FakeSessionManager sessionManager = new();
|
|
RecordingAuditWriter auditWriter = new();
|
|
DashboardSessionAdminService service = CreateService(sessionManager, auditWriter);
|
|
|
|
DashboardSessionAdminResult result = await service.KillWorkerAsync(
|
|
CreateUser(DashboardRoles.Admin),
|
|
"session-1",
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
AuditEvent audit = Assert.Single(auditWriter.Events);
|
|
Assert.Equal(DashboardSessionAdminService.KillWorkerAction, audit.Action);
|
|
Assert.Equal("session-1", audit.Target);
|
|
Assert.Equal(AuditOutcome.Success, audit.Outcome);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an unauthorized (viewer) close attempt still writes a <c>Denied</c>
|
|
/// audit row, so rejected destructive attempts are durably recorded.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CloseSessionAsync_ViewerDenied_WritesDeniedAuditEvent()
|
|
{
|
|
FakeSessionManager sessionManager = new();
|
|
RecordingAuditWriter auditWriter = new();
|
|
DashboardSessionAdminService service = CreateService(sessionManager, auditWriter);
|
|
|
|
DashboardSessionAdminResult result = await service.CloseSessionAsync(
|
|
CreateUser(DashboardRoles.Viewer),
|
|
"session-1",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
AuditEvent audit = Assert.Single(auditWriter.Events);
|
|
Assert.Equal(DashboardSessionAdminService.CloseSessionAction, audit.Action);
|
|
Assert.Equal(AuditOutcome.Denied, audit.Outcome);
|
|
}
|
|
|
|
private static DashboardSessionAdminService CreateService(
|
|
ISessionManager sessionManager,
|
|
IAuditWriter? auditWriter = null)
|
|
{
|
|
DefaultHttpContext httpContext = new();
|
|
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
|
|
|
return new DashboardSessionAdminService(
|
|
sessionManager,
|
|
new HttpContextAccessor { HttpContext = httpContext },
|
|
auditWriter ?? new RecordingAuditWriter());
|
|
}
|
|
|
|
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 RecordingAuditWriter : IAuditWriter
|
|
{
|
|
private readonly ConcurrentQueue<AuditEvent> _events = new();
|
|
|
|
/// <summary>Gets the audit events written through this writer, in order.</summary>
|
|
public IReadOnlyList<AuditEvent> Events => _events.ToArray();
|
|
|
|
/// <summary>Records the given audit event for later inspection by the test.</summary>
|
|
/// <param name="evt">The audit event to record.</param>
|
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
|
{
|
|
_events.Enqueue(evt);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSessionManager : ISessionManager
|
|
{
|
|
/// <summary>Gets the number of times CloseSessionAsync was invoked.</summary>
|
|
public int CloseCount { get; private set; }
|
|
|
|
/// <summary>Gets the number of times KillWorkerAsync was invoked.</summary>
|
|
public int KillCount { get; private set; }
|
|
|
|
/// <summary>Gets the last session ID passed to CloseSessionAsync.</summary>
|
|
public string? LastClosedSessionId { get; private set; }
|
|
|
|
/// <summary>Gets the last session ID passed to KillWorkerAsync.</summary>
|
|
public string? LastKilledSessionId { get; private set; }
|
|
|
|
/// <summary>Gets the last reason string passed to KillWorkerAsync.</summary>
|
|
public string? LastKillReason { get; private set; }
|
|
|
|
/// <summary>Gets a value indicating whether CloseSessionAsync should throw SessionNotFound.</summary>
|
|
public bool CloseThrowsNotFound { get; init; }
|
|
|
|
/// <summary>Gets the exception CloseSessionAsync should throw unexpectedly.</summary>
|
|
public Exception? CloseThrowsUnexpected { get; init; }
|
|
|
|
/// <summary>Gets the exception KillWorkerAsync should throw unexpectedly.</summary>
|
|
public Exception? KillThrowsUnexpected { get; init; }
|
|
|
|
/// <inheritdoc />
|
|
public Task<GatewaySession> OpenSessionAsync(
|
|
SessionOpenRequest request,
|
|
string? clientIdentity,
|
|
string? ownerKeyId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryGetSession(
|
|
string sessionId,
|
|
[MaybeNullWhen(false)] out GatewaySession session)
|
|
{
|
|
session = null;
|
|
return false;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<WorkerCommandReply> InvokeAsync(
|
|
string sessionId,
|
|
WorkerCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<SessionCloseResult> CloseSessionAsync(
|
|
string sessionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
CloseCount++;
|
|
LastClosedSessionId = sessionId;
|
|
if (CloseThrowsNotFound)
|
|
{
|
|
throw new SessionManagerException(
|
|
SessionManagerErrorCode.SessionNotFound,
|
|
$"Session {sessionId} was not found.");
|
|
}
|
|
|
|
if (CloseThrowsUnexpected is not null)
|
|
{
|
|
throw CloseThrowsUnexpected;
|
|
}
|
|
|
|
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<SessionCloseResult> KillWorkerAsync(
|
|
string sessionId,
|
|
string reason,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
KillCount++;
|
|
LastKilledSessionId = sessionId;
|
|
LastKillReason = reason;
|
|
if (KillThrowsUnexpected is not null)
|
|
{
|
|
throw KillThrowsUnexpected;
|
|
}
|
|
|
|
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<int> CloseExpiredLeasesAsync(
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(0);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|