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; } } }