using System.Globalization; using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Galaxy; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Server.Workers; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardSnapshotServiceTests { /// /// Verifies snapshot returns empty collections and healthy status when registry is empty. /// [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); } /// /// Verifies snapshot projects active, faulted, and closed session states with worker and metrics data. /// [Fact] public void GetSnapshot_ProjectsActiveAndFaultedSessionsWorkersMetricsAndFaults() { SessionRegistry registry = new(); GatewaySession activeSession = CreateSession( "session-active", "client-one", DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture)); 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", CultureInfo.InvariantCulture)); faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted)); faultedSession.MarkFaulted("worker pipe disconnected"); GatewaySession closedSession = CreateSession( "session-closed", "client-three", DateTimeOffset.Parse("2026-04-26T09:59:00Z", CultureInfo.InvariantCulture)); closedSession.AttachWorkerClient(new FakeWorkerClient("session-closed", 1203, WorkerClientState.Closed)); closedSession.TransitionTo(SessionState.Closed); registry.TryAdd(activeSession); registry.TryAdd(faultedSession); registry.TryAdd(closedSession); 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(3, snapshot.Sessions.Count); Assert.Equal("session-faulted", snapshot.Sessions[0].SessionId); Assert.Equal(SessionState.Faulted, snapshot.Sessions[0].State); DashboardSessionSummary activeSummary = Assert.Single( snapshot.Sessions, session => session.SessionId == "session-active"); Assert.Equal(1, activeSummary.EventsReceived); Assert.Equal(2, snapshot.Workers.Count); Assert.DoesNotContain(snapshot.Workers, worker => worker.SessionId == "session-closed"); 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); } /// /// Verifies snapshot redacts sensitive values from client identity, session name, and fault messages. /// [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", CultureInfo.InvariantCulture), 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); } /// /// Verifies snapshot generation does not mutate session or worker client state. /// [Fact] public void GetSnapshot_DoesNotMutateSessionOrWorkerState() { SessionRegistry registry = new(); GatewaySession session = CreateSession( "session-active", "client-one", DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture)); 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); } /// /// Verifies snapshot respects configured limits for recent sessions and faults. /// [Fact] public void GetSnapshot_AppliesRecentSessionAndFaultLimits() { SessionRegistry registry = new(); GatewaySession olderSession = CreateSession( "session-older", "client-one", DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture)); GatewaySession newerSession = CreateSession( "session-newer", "client-two", DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture)); 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); } /// /// Verifies snapshot projects Galaxy hierarchy cache data including templates and categories. /// [Fact] public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache() { GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, Sequence = 7, LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture), DashboardSummary = new DashboardGalaxySummary( DashboardGalaxyStatus.Healthy, LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture), LastError: null, ObjectCount: 3, AreaCount: 1, AttributeCount: 2, HistorizedAttributeCount: 1, AlarmAttributeCount: 1, TopTemplates: [ new DashboardGalaxyTemplateUsage("$Pump", 2), new DashboardGalaxyTemplateUsage("$Area", 1), ], ObjectCategories: [ new DashboardGalaxyCategoryCount(10, "UserDefined", 2), new DashboardGalaxyCategoryCount(13, "Area", 1), ]), ObjectCount = 3, AreaCount = 1, AttributeCount = 2, HistorizedAttributeCount = 1, AlarmAttributeCount = 1, }; using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService( new SessionRegistry(), metrics, galaxyHierarchyCache: new StubGalaxyHierarchyCache(entry)); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Equal(DashboardGalaxyStatus.Healthy, snapshot.Galaxy.Status); Assert.Equal(3, snapshot.Galaxy.ObjectCount); Assert.Equal(1, snapshot.Galaxy.AreaCount); Assert.Equal(2, snapshot.Galaxy.AttributeCount); Assert.Equal("$Pump", Assert.Single(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Pump").TemplateName); Assert.Equal(2, snapshot.Galaxy.TopTemplates.First(t => t.TemplateName == "$Pump").InstanceCount); Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "UserDefined" && c.ObjectCount == 2); Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1); } /// /// Verifies snapshot watcher cancels cleanly when subscriber cancels. /// [Fact] public void GetSnapshot_DoesNotSynchronouslyListApiKeys() { using GatewayMetrics metrics = new(); CountingApiKeyAdminStore apiKeyAdminStore = new(); DashboardSnapshotService service = CreateService( new SessionRegistry(), metrics, apiKeyAdminStore: apiKeyAdminStore); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Empty(snapshot.ApiKeys); Assert.Equal(0, apiKeyAdminStore.ListCount); } /// Verifies that snapshot service refreshes API key summaries before each snapshot. [Fact] public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot() { using GatewayMetrics metrics = new(); CountingApiKeyAdminStore apiKeyAdminStore = new( new ApiKeyRecord( KeyId: "operator01", KeyPrefix: "mxgw_operator01", SecretHash: [1, 2, 3], DisplayName: "Operator", Scopes: new HashSet([GatewayScopes.MetadataRead], StringComparer.Ordinal), Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = ["Area1/*"], }, CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture), LastUsedUtc: null, RevokedUtc: null)); DashboardSnapshotService service = CreateService( new SessionRegistry(), metrics, apiKeyAdminStore: apiKeyAdminStore); using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2)); await using IAsyncEnumerator enumerator = service .WatchSnapshotsAsync(cancellation.Token) .GetAsyncEnumerator(cancellation.Token); Assert.True(await enumerator.MoveNextAsync()); DashboardSnapshot snapshot = enumerator.Current; DashboardApiKeySummary key = Assert.Single(snapshot.ApiKeys); Assert.Equal("operator01", key.KeyId); Assert.Equal(["Area1/*"], key.Constraints.BrowseSubtrees); Assert.Equal(1, apiKeyAdminStore.ListCount); } /// Verifies that snapshot service reuses previous summaries when API key refresh fails. [Fact] public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries() { using GatewayMetrics metrics = new(); SequencedApiKeyAdminStore apiKeyAdminStore = new( new ApiKeyRecord( KeyId: "operator01", KeyPrefix: "mxgw_operator01", SecretHash: [1, 2, 3], DisplayName: "Operator", Scopes: new HashSet([GatewayScopes.MetadataRead], StringComparer.Ordinal), Constraints: ApiKeyConstraints.Empty, CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture), LastUsedUtc: null, RevokedUtc: null)); DashboardSnapshotService service = CreateService( new SessionRegistry(), metrics, new GatewayOptions { Dashboard = new DashboardOptions { SnapshotIntervalMilliseconds = 1, }, }, apiKeyAdminStore: apiKeyAdminStore); using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2)); await using IAsyncEnumerator enumerator = service .WatchSnapshotsAsync(cancellation.Token) .GetAsyncEnumerator(cancellation.Token); Assert.True(await enumerator.MoveNextAsync()); DashboardSnapshot first = enumerator.Current; apiKeyAdminStore.FailNext = true; Assert.True(await enumerator.MoveNextAsync()); DashboardSnapshot second = enumerator.Current; Assert.Equal("operator01", Assert.Single(first.ApiKeys).KeyId); Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId); Assert.Equal(2, apiKeyAdminStore.ListCount); } /// Verifies that snapshot service disposes cleanly when subscriber cancels. [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, IGalaxyHierarchyCache? galaxyHierarchyCache = null, IApiKeyAdminStore? apiKeyAdminStore = null) { GatewayOptions resolvedOptions = options ?? new GatewayOptions { Dashboard = new DashboardOptions { SnapshotIntervalMilliseconds = 1, }, }; GatewayConfigurationProvider configurationProvider = new(Options.Create(resolvedOptions)); return new DashboardSnapshotService( registry, metrics, configurationProvider, galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty), apiKeyAdminStore ?? new FakeApiKeyAdminStore(), Options.Create(resolvedOptions)); } private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { /// /// Gets the current Galaxy hierarchy cache entry. /// public GalaxyHierarchyCacheEntry Current { get; } = current; /// /// Refreshes the cache asynchronously. /// /// Cancellation token. /// Completed task. public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// /// Waits for the first cache load asynchronously. /// /// Cancellation token. /// Completed task. public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } private class FakeApiKeyAdminStore : IApiKeyAdminStore { /// public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) { return Task.CompletedTask; } /// public virtual Task> ListAsync(CancellationToken cancellationToken) { return Task.FromResult>([]); } /// public Task RevokeAsync( string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken) { return Task.FromResult(false); } /// public Task RotateAsync( string keyId, byte[] secretHash, DateTimeOffset rotatedUtc, CancellationToken cancellationToken) { return Task.FromResult(false); } /// public Task DeleteAsync(string keyId, CancellationToken cancellationToken) { return Task.FromResult(false); } } private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore { /// Gets the count of list operations performed. public int ListCount { get; protected set; } /// public override Task> ListAsync(CancellationToken cancellationToken) { ListCount++; return Task.FromResult>(records); } } private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record) { /// Gets or sets a value indicating whether the next list operation should fail. public bool FailNext { get; set; } /// public override Task> ListAsync(CancellationToken cancellationToken) { if (FailNext) { FailNext = false; ListCount++; throw new InvalidOperationException("Simulated SQLite failure."); } return base.ListAsync(cancellationToken); } } 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 { /// /// Gets the session identifier. /// public string SessionId { get; } = sessionId; /// /// Gets the process identifier. /// public int? ProcessId { get; } = processId; /// /// Gets the current worker client state. /// public WorkerClientState State { get; private set; } = state; /// /// Gets the timestamp of the last heartbeat. /// public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z", CultureInfo.InvariantCulture); /// /// Gets the count of start invocations. /// public int StartCount { get; private set; } /// /// Gets the count of shutdown invocations. /// public int ShutdownCount { get; private set; } /// /// Gets the count of kill invocations. /// public int KillCount { get; private set; } /// /// Starts the worker client asynchronously. /// /// Cancellation token. /// Completed task. public Task StartAsync(CancellationToken cancellationToken) { StartCount++; return Task.CompletedTask; } /// /// Invokes a worker command asynchronously. /// /// The command to invoke. /// Command timeout. /// Cancellation token. /// Command reply. public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) { return Task.FromResult(new WorkerCommandReply()); } /// /// Reads events from the worker asynchronously. /// /// Cancellation token. /// Async enumerable of worker events. public async IAsyncEnumerable ReadEventsAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; yield break; } /// /// Shuts down the worker client asynchronously. /// /// Shutdown timeout. /// Cancellation token. /// Completed task. public Task ShutdownAsync( TimeSpan timeout, CancellationToken cancellationToken) { ShutdownCount++; State = WorkerClientState.Closed; return Task.CompletedTask; } /// /// Terminates the worker client. /// /// Reason for termination. public void Kill(string reason) { KillCount++; State = WorkerClientState.Faulted; } /// /// Releases resources used by this worker client. /// /// Completed value task. public ValueTask DisposeAsync() { return ValueTask.CompletedTask; } } }