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); // Tests-028: pin the literal reason string so a future caller-side change is a deliberate // test update rather than a silent drift. DashboardSessionAdminService passes a hard-coded // "dashboard-admin-kill" so the worker-exit metric (mxgateway.workers.killed) carries a // stable, machine-greppable reason tag. Assert.Equal("dashboard-admin-kill", 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); } /// /// Tests-029: 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. /// [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); } [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 for Server-050: 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. /// [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 for Server-050: same friendly-fail contract for the Kill path. /// [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)); } 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 Exception? CloseThrowsUnexpected { get; init; } public Exception? KillThrowsUnexpected { 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."); } 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; } } }