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
{
/// Verifies that a viewer cannot close a session.
/// A task that represents the asynchronous operation.
[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);
}
/// Verifies that an admin can close a session.
/// A task that represents the asynchronous operation.
[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);
}
/// Verifies that closing a missing session returns a friendly error message.
/// A task that represents the asynchronous operation.
[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);
}
/// Verifies that a viewer cannot kill a worker.
/// A task that represents the asynchronous operation.
[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);
}
/// Verifies that an admin can kill a worker.
/// A task that represents the asynchronous operation.
[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);
}
/// Verifies that killing a worker with a blank session ID returns failure.
/// A task that represents the asynchronous operation.
[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);
}
///
/// CloseSessionAsync has the same blank-session-id guard as
/// KillWorkerAsync but previously had no parallel test. Coverage was asymmetric.
/// A guard-removal regression on the close path would slip through.
///
/// A task that represents the asynchronous operation.
[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);
}
/// Verifies that CanManage rejects unauthenticated users and viewers.
[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)));
}
///
/// Regression: an unexpected (non-)
/// exception from CloseSessionAsync — e.g. an
/// or surfaced from RemoveSessionAsync/DisposeAsync —
/// must be converted to a friendly
/// rather than propagating raw into Blazor's error boundary.
///
/// A task that represents the asynchronous operation.
[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));
}
///
/// Regression: same friendly-fail contract for the Kill path.
///
/// A task that represents the asynchronous operation.
[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));
}
///
/// Verifies that a successful close writes a canonical dashboard-close-session
/// — actor, session-id target, and Success outcome — to the
/// audit store, not only the operational log.
///
/// A task that represents the asynchronous operation.
[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);
}
///
/// Verifies that a successful kill writes a canonical dashboard-kill-worker
/// to the audit store.
///
/// A task that represents the asynchronous operation.
[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);
}
///
/// Verifies that an unauthorized (viewer) close attempt still writes a Denied
/// audit row, so rejected destructive attempts are durably recorded.
///
/// A task that represents the asynchronous operation.
[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 _events = new();
/// Gets the audit events written through this writer, in order.
public IReadOnlyList Events => _events.ToArray();
/// Records the given audit event for later inspection by the test.
/// The audit event to record.
/// Token to cancel the asynchronous operation.
/// A task that represents the asynchronous operation.
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
_events.Enqueue(evt);
return Task.CompletedTask;
}
}
private sealed class FakeSessionManager : ISessionManager
{
/// Gets the number of times CloseSessionAsync was invoked.
public int CloseCount { get; private set; }
/// Gets the number of times KillWorkerAsync was invoked.
public int KillCount { get; private set; }
/// Gets the last session ID passed to CloseSessionAsync.
public string? LastClosedSessionId { get; private set; }
/// Gets the last session ID passed to KillWorkerAsync.
public string? LastKilledSessionId { get; private set; }
/// Gets the last reason string passed to KillWorkerAsync.
public string? LastKillReason { get; private set; }
/// Gets a value indicating whether CloseSessionAsync should throw SessionNotFound.
public bool CloseThrowsNotFound { get; init; }
/// Gets the exception CloseSessionAsync should throw unexpectedly.
public Exception? CloseThrowsUnexpected { get; init; }
/// Gets the exception KillWorkerAsync should throw unexpectedly.
public Exception? KillThrowsUnexpected { get; init; }
///
public Task OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
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 Task 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));
}
///
public Task 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));
}
///
public Task CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
{
return Task.FromResult(0);
}
///
public Task ShutdownAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
}