using Microsoft.Extensions.Options; using MxGateway.Contracts.Proto; using MxGateway.Server.Configuration; using MxGateway.Server.Dashboard; using MxGateway.Server.Metrics; using MxGateway.Server.Sessions; using MxGateway.Server.Workers; namespace MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardSnapshotServiceTests { [Fact] public void GetSnapshot_WhenRegistryEmpty_ReturnsEmptyOperationalState() { using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Empty(snapshot.Sessions); Assert.Empty(snapshot.Workers); Assert.Empty(snapshot.Faults); Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.sessions.open" && metric.Value == 0); Assert.Equal("Healthy", snapshot.GatewayStatus); Assert.NotNull(snapshot.Configuration); } [Fact] public void GetSnapshot_ProjectsActiveAndFaultedSessionsWorkersMetricsAndFaults() { SessionRegistry registry = new(); GatewaySession activeSession = CreateSession( "session-active", "client-one", DateTimeOffset.Parse("2026-04-26T10:00:00Z")); activeSession.AttachWorkerClient(new FakeWorkerClient("session-active", 1201, WorkerClientState.Ready)); activeSession.MarkReady(); GatewaySession faultedSession = CreateSession( "session-faulted", "client-two", DateTimeOffset.Parse("2026-04-26T10:01:00Z")); faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted)); faultedSession.MarkFaulted("worker pipe disconnected"); registry.TryAdd(activeSession); registry.TryAdd(faultedSession); using GatewayMetrics metrics = new(); metrics.SessionOpened(); metrics.SessionOpened(); metrics.CommandStarted("Register"); metrics.CommandFailed("Register", "WorkerFaulted", TimeSpan.FromMilliseconds(7)); metrics.EventReceived("session-active", "OnDataChange"); metrics.Fault("WorkerFaulted"); DashboardSnapshotService service = CreateService(registry, metrics); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Equal(2, snapshot.Sessions.Count); Assert.Equal("session-faulted", snapshot.Sessions[0].SessionId); Assert.Equal(SessionState.Faulted, snapshot.Sessions[0].State); Assert.Equal(2, snapshot.Workers.Count); Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.commands.started" && metric.Value == 1); Assert.Contains( snapshot.Metrics, metric => metric.Name == "mxgateway.events.received" && metric.Dimension == "OnDataChange" && metric.Value == 1); DashboardFaultSummary fault = Assert.Single(snapshot.Faults); Assert.Equal("Worker", fault.Source); Assert.Equal("session-faulted", fault.SessionId); Assert.Equal("worker pipe disconnected", fault.Message); } [Fact] public void GetSnapshot_RedactsSecretsFromSessionAndFaultFields() { SessionRegistry registry = new(); GatewaySession session = CreateSession( "session-redacted", "Bearer mxgw_admin_super-secret", DateTimeOffset.Parse("2026-04-26T10:00:00Z"), clientSessionName: "password=hunter2", clientCorrelationId: "token=abc123"); session.MarkFaulted("secret=credential-value"); registry.TryAdd(session); using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService(registry, metrics); DashboardSnapshot snapshot = service.GetSnapshot(); DashboardSessionSummary summary = Assert.Single(snapshot.Sessions); Assert.Equal("Bearer mxgw_admin_[redacted]", summary.ClientIdentity); Assert.Equal("[redacted]", summary.ClientSessionName); Assert.Equal("[redacted]", summary.ClientCorrelationId); Assert.Equal("[redacted]", summary.LastFault); Assert.Equal("[redacted]", Assert.Single(snapshot.Faults).Message); Assert.Equal("[redacted]", snapshot.Configuration.Authentication.PepperSecretName); } [Fact] public void GetSnapshot_DoesNotMutateSessionOrWorkerState() { SessionRegistry registry = new(); GatewaySession session = CreateSession( "session-active", "client-one", DateTimeOffset.Parse("2026-04-26T10:00:00Z")); FakeWorkerClient workerClient = new("session-active", 1201, WorkerClientState.Ready); session.AttachWorkerClient(workerClient); session.MarkReady(); registry.TryAdd(session); using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService(registry, metrics); service.GetSnapshot(); service.GetSnapshot(); Assert.Equal(1, registry.ActiveCount); Assert.Equal(SessionState.Ready, session.State); Assert.Equal(WorkerClientState.Ready, workerClient.State); Assert.Equal(0, workerClient.StartCount); Assert.Equal(0, workerClient.ShutdownCount); Assert.Equal(0, workerClient.KillCount); } [Fact] public void GetSnapshot_AppliesRecentSessionAndFaultLimits() { SessionRegistry registry = new(); GatewaySession olderSession = CreateSession( "session-older", "client-one", DateTimeOffset.Parse("2026-04-26T10:00:00Z")); GatewaySession newerSession = CreateSession( "session-newer", "client-two", DateTimeOffset.Parse("2026-04-26T10:01:00Z")); olderSession.MarkFaulted("older fault"); newerSession.MarkFaulted("newer fault"); registry.TryAdd(olderSession); registry.TryAdd(newerSession); using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService( registry, metrics, new GatewayOptions { Dashboard = new DashboardOptions { SnapshotIntervalMilliseconds = 1, RecentSessionLimit = 1, RecentFaultLimit = 1, }, }); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Equal("session-newer", Assert.Single(snapshot.Sessions).SessionId); Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId); } [Fact] public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly() { using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService( new SessionRegistry(), metrics, new GatewayOptions { Dashboard = new DashboardOptions { SnapshotIntervalMilliseconds = 1, }, }); using CancellationTokenSource cancellation = new(); await using IAsyncEnumerator enumerator = service .WatchSnapshotsAsync(cancellation.Token) .GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))); await cancellation.CancelAsync(); bool hasNext = await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); Assert.False(hasNext); } private static DashboardSnapshotService CreateService( SessionRegistry registry, GatewayMetrics metrics, GatewayOptions? options = null) { GatewayOptions resolvedOptions = options ?? new GatewayOptions { Dashboard = new DashboardOptions { SnapshotIntervalMilliseconds = 1, }, }; GatewayConfigurationProvider configurationProvider = new(Options.Create(resolvedOptions)); return new DashboardSnapshotService( registry, metrics, configurationProvider, Options.Create(resolvedOptions)); } private static GatewaySession CreateSession( string sessionId, string? clientIdentity, DateTimeOffset openedAt, string? clientSessionName = "test-session", string? clientCorrelationId = "client-correlation") { return new GatewaySession( sessionId, "mxaccess", $"mxaccess-gateway-1-{sessionId}", "nonce", clientIdentity, clientSessionName, clientCorrelationId, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), openedAt); } private sealed class FakeWorkerClient( string sessionId, int? processId, WorkerClientState state) : IWorkerClient { public string SessionId { get; } = sessionId; public int? ProcessId { get; } = processId; public WorkerClientState State { get; private set; } = state; public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z"); public int StartCount { get; private set; } public int ShutdownCount { get; private set; } public int KillCount { get; private set; } public Task StartAsync(CancellationToken cancellationToken) { StartCount++; return Task.CompletedTask; } public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) { return Task.FromResult(new WorkerCommandReply()); } public async IAsyncEnumerable ReadEventsAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; yield break; } public Task ShutdownAsync( TimeSpan timeout, CancellationToken cancellationToken) { ShutdownCount++; State = WorkerClientState.Closed; return Task.CompletedTask; } public void Kill(string reason) { KillCount++; State = WorkerClientState.Faulted; } public ValueTask DisposeAsync() { return ValueTask.CompletedTask; } } }