From 094f2ffee4d8cc3a4c776c17276cf01ac38e9897 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 07:15:24 -0400 Subject: [PATCH] feat(dashboard): settings page shows GroupToTag and UntaggedSessionVisibility --- .../EffectiveDashboardConfiguration.cs | 4 +- .../GatewayConfigurationProvider.cs | 10 +- .../Components/Pages/SettingsPage.razor | 22 +++ .../GatewayConfigurationProviderTests.cs | 164 ++++++++++++++++++ .../SettingsPageTagVisibilityRenderTests.cs | 136 +++++++++++++++ 5 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigurationProviderTests.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SettingsPageTagVisibilityRenderTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs index 9db3a8b..7e9de5f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs @@ -7,4 +7,6 @@ public sealed record EffectiveDashboardConfiguration( int RecentFaultLimit, int RecentSessionLimit, bool ShowTagValues, - IReadOnlyDictionary GroupToRole); + IReadOnlyDictionary GroupToRole, + IReadOnlyDictionary> GroupToTag, + UntaggedSessionVisibility UntaggedSessionVisibility); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs index ae66052..0b30aa0 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs @@ -62,7 +62,15 @@ public sealed class GatewayConfigurationProvider(IOptions option RecentFaultLimit: value.Dashboard.RecentFaultLimit, RecentSessionLimit: value.Dashboard.RecentSessionLimit, ShowTagValues: value.Dashboard.ShowTagValues, - GroupToRole: value.Dashboard.GroupToRole), + GroupToRole: value.Dashboard.GroupToRole, + // Rebuilt rather than passed through because the value type widens from string[] + // to IReadOnlyList; the comparer is carried over so the projected map + // still matches LDAP group names in whatever case the directory returns them. + GroupToTag: value.Dashboard.GroupToTag.ToDictionary( + pair => pair.Key, + pair => (IReadOnlyList)pair.Value, + StringComparer.OrdinalIgnoreCase), + UntaggedSessionVisibility: value.Dashboard.UntaggedSessionVisibility), Protocol: new EffectiveProtocolConfiguration( value.Protocol.WorkerProtocolVersion, value.Protocol.MaxGrpcMessageBytes)); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor index 2d57f39..5600287 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor @@ -65,6 +65,27 @@ else } + + Dashboard tag mapping + @* Group and tag NAMES are configuration, not tag values — the redaction + rule does not reach them, and an operator asking why a Viewer sees no + sessions needs this map as much as the role map above it. *@ + + @if (Snapshot.Configuration.Dashboard.GroupToTag.Count == 0) + { + (none configured) + } + else + { +
    + @foreach (KeyValuePair> pair in Snapshot.Configuration.Dashboard.GroupToTag) + { +
  • @pair.Key → @string.Join(", ", pair.Value)
  • + } +
+ } + + Worker executable@Snapshot.Configuration.Worker.ExecutablePath Worker architecture@Snapshot.Configuration.Worker.RequiredArchitecture Startup timeout@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds @@ -78,6 +99,7 @@ else Anonymous localhost@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost Snapshot interval@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms Show tag values@Snapshot.Configuration.Dashboard.ShowTagValues + Untagged session visibility@Snapshot.Configuration.Dashboard.UntaggedSessionVisibility Worker protocol@Snapshot.Configuration.Protocol.WorkerProtocolVersion diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigurationProviderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigurationProviderTests.cs new file mode 100644 index 0000000..3b5a148 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigurationProviderTests.cs @@ -0,0 +1,164 @@ +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Tests.Configuration; + +/// +/// Covers the projection makes from bound +/// onto the effective-configuration record the dashboard renders. +/// +/// +/// The projection is hand-written member by member, so an option that exists and validates can +/// still be invisible on the settings page — which is exactly what happened to the two SEC-25 +/// dashboard-ACL options. These tests pin the dashboard-tag members to the options they come +/// from; the redaction assertions pin the opposite invariant, that the two secret-bearing members +/// are the only ones masked. +/// +public sealed class GatewayConfigurationProviderTests +{ + /// The group → tag map reaches the projection with its groups, tags and ordering intact. + [Fact] + public void GetEffectiveConfiguration_CopiesGroupToTagMapping() + { + GatewayOptions options = new() + { + Dashboard = new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // A multi-tag group is the interesting case: a projection that flattened the + // array to its first element, or to a joined string, would still satisfy a + // single-tag fixture. + ["GwOps"] = ["team-a", "team-b"], + ["GwViewers"] = ["team-a"], + }, + }, + }; + + EffectiveDashboardConfiguration dashboard = Project(options).Dashboard; + + Assert.Equal(2, dashboard.GroupToTag.Count); + Assert.Equal(["team-a", "team-b"], dashboard.GroupToTag["GwOps"]); + Assert.Equal(["team-a"], dashboard.GroupToTag["GwViewers"]); + } + + /// + /// The map's case-insensitive lookup survives the projection. LDAP group names arrive in + /// whatever case the directory returns them, so a projection that rebuilt the dictionary with + /// the default ordinal comparer would silently stop matching. + /// + [Fact] + public void GetEffectiveConfiguration_KeepsGroupToTagLookupCaseInsensitive() + { + GatewayOptions options = new() + { + Dashboard = new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwOps"] = ["team-a"], + }, + }, + }; + + EffectiveDashboardConfiguration dashboard = Project(options).Dashboard; + + Assert.True(dashboard.GroupToTag.ContainsKey("gwops")); + } + + /// + /// An unconfigured map projects as empty rather than null — the settings page renders the row + /// either way, and "none configured" is the operationally interesting answer. + /// + [Fact] + public void GetEffectiveConfiguration_WhenNoTagsConfigured_ProjectsEmptyMapping() + { + EffectiveDashboardConfiguration dashboard = Project(new GatewayOptions()).Dashboard; + + Assert.Empty(dashboard.GroupToTag); + Assert.Empty(dashboard.GroupToRole); + } + + /// The untagged-session visibility policy is projected, defaulting to the fail-closed value. + [Fact] + public void GetEffectiveConfiguration_CopiesUntaggedSessionVisibility() + { + Assert.Equal( + UntaggedSessionVisibility.AdminOnly, + Project(new GatewayOptions()).Dashboard.UntaggedSessionVisibility); + + GatewayOptions widened = new() + { + Dashboard = new DashboardOptions + { + UntaggedSessionVisibility = UntaggedSessionVisibility.AllViewers, + }, + }; + + Assert.Equal( + UntaggedSessionVisibility.AllViewers, + Project(widened).Dashboard.UntaggedSessionVisibility); + } + + /// The pre-existing dashboard members keep coming from the options they always did. + [Fact] + public void GetEffectiveConfiguration_CopiesTheOtherDashboardMembers() + { + GatewayOptions options = new() + { + Dashboard = new DashboardOptions + { + Enabled = false, + AllowAnonymousLocalhost = false, + SnapshotIntervalMilliseconds = 2_500, + RecentFaultLimit = 7, + RecentSessionLimit = 11, + ShowTagValues = true, + GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwAdmins"] = "Administrator", + }, + }, + }; + + EffectiveDashboardConfiguration dashboard = Project(options).Dashboard; + + Assert.False(dashboard.Enabled); + Assert.False(dashboard.AllowAnonymousLocalhost); + Assert.Equal(2_500, dashboard.SnapshotIntervalMilliseconds); + Assert.Equal(7, dashboard.RecentFaultLimit); + Assert.Equal(11, dashboard.RecentSessionLimit); + Assert.True(dashboard.ShowTagValues); + Assert.Equal("Administrator", dashboard.GroupToRole["GwAdmins"]); + } + + /// + /// The masking boundary. Only the pepper name and the LDAP bind password are redacted; the + /// tag mapping is configuration, not a secret, so masking it would hide the very thing the + /// settings page was extended to show. + /// + [Fact] + public void GetEffectiveConfiguration_RedactsOnlyTheSecretBearingMembers() + { + GatewayOptions options = new() + { + Ldap = new LdapOptions { ServiceAccountPassword = "bind-password" }, + Dashboard = new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwOps"] = ["team-a"], + }, + }, + }; + + EffectiveGatewayConfiguration configuration = Project(options); + + Assert.Equal(GatewayConfigurationProvider.RedactedValue, configuration.Authentication.PepperSecretName); + Assert.Equal(GatewayConfigurationProvider.RedactedValue, configuration.Ldap.ServiceAccountPassword); + Assert.Equal(["team-a"], configuration.Dashboard.GroupToTag["GwOps"]); + } + + private static EffectiveGatewayConfiguration Project(GatewayOptions options) => + new GatewayConfigurationProvider(Options.Create(options)).GetEffectiveConfiguration(); +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SettingsPageTagVisibilityRenderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SettingsPageTagVisibilityRenderTests.cs new file mode 100644 index 0000000..5de301a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SettingsPageTagVisibilityRenderTests.cs @@ -0,0 +1,136 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Components.Web.HtmlRendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages; +using HtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer; + +namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; + +/// +/// Renders and asserts the two SEC-25 dashboard-ACL options — +/// Dashboard:GroupToTag and Dashboard:UntaggedSessionVisibility — reach the page. +/// +/// +/// +/// The provider tests one folder over prove the projection carries the values; they would stay +/// green with no row on the page at all. An operator debugging why a Viewer sees no sessions +/// reads the settings page, not the record, so the markup is where the evidence has to be. +/// +/// +/// Tag NAMES are configuration, like the group → role mapping rendered beside them. No tag VALUE +/// is involved, so nothing here is subject to the value-redaction rule. +/// +/// +/// Static rendering via the framework's , the idiom used by +/// SecretsNavRenderTests and AlarmsPageTruncationBannerTests — the assertion is +/// about markup the server emits, so no component-testing dependency is warranted. +/// +/// +public sealed class SettingsPageTagVisibilityRenderTests +{ + private const string EmptyMarker = "(none configured)"; + + /// A configured mapping renders its group and every tag, plus the visibility policy. + /// A task that represents the asynchronous operation. + [Fact] + public async Task SettingsPage_WhenTagsConfigured_RendersGroupsTagsAndVisibility() + { + string html = await RenderAsync(new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwOps"] = ["team-a", "team-b"], + }, + UntaggedSessionVisibility = UntaggedSessionVisibility.AllViewers, + }); + + Assert.Contains("GwOps", html, StringComparison.Ordinal); + // Both tags, not just the first: a row that rendered only the head of the array would be + // actively misleading about which sessions a group can observe. + Assert.Contains("team-a", html, StringComparison.Ordinal); + Assert.Contains("team-b", html, StringComparison.Ordinal); + Assert.Contains(nameof(UntaggedSessionVisibility.AllViewers), html, StringComparison.Ordinal); + } + + /// + /// The unconfigured deployment — the common one, and the one whose Viewers see nothing. The + /// row must still render, saying so, and the fail-closed default must be on the page. The + /// role-mapping heading is the control that keeps this from passing over a blank page. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task SettingsPage_WhenNoTagsConfigured_RendersTheEmptyStateAndDefaultVisibility() + { + string html = await RenderAsync(new DashboardOptions()); + + Assert.Contains("Dashboard tag mapping", html, StringComparison.Ordinal); + Assert.Contains(EmptyMarker, html, StringComparison.Ordinal); + Assert.Contains(nameof(UntaggedSessionVisibility.AdminOnly), html, StringComparison.Ordinal); + Assert.Contains("Dashboard role mapping", html, StringComparison.Ordinal); + } + + private static async Task RenderAsync(DashboardOptions dashboard) + { + EffectiveGatewayConfiguration configuration = + new GatewayConfigurationProvider(Options.Create(new GatewayOptions { Dashboard = dashboard })) + .GetEffectiveConfiguration(); + + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(new StubSnapshotService(configuration)); + services.AddSingleton(new IdleSnapshotFeed()); + + await using ServiceProvider provider = services.BuildServiceProvider(); + await using HtmlRenderer renderer = new( + provider, + provider.GetRequiredService()); + + return await renderer.Dispatcher.InvokeAsync(async () => + { + HtmlRootComponent output = await renderer.RenderComponentAsync(); + return output.ToHtmlString(); + }); + } + + // Seeds the page's first (and only) render. Everything except Configuration is inert here. + private sealed class StubSnapshotService(EffectiveGatewayConfiguration configuration) + : IDashboardSnapshotService + { + /// + public DashboardSnapshot GetSnapshot() => new( + GeneratedAt: DateTimeOffset.UnixEpoch, + GatewayStartedAt: DateTimeOffset.UnixEpoch, + GatewayUptime: TimeSpan.Zero, + GatewayStatus: "Healthy", + GatewayVersion: "test", + Sessions: [], + Workers: [], + Metrics: [], + Faults: [], + ApiKeys: [], + Configuration: configuration, + Galaxy: null!); + + /// + public IAsyncEnumerable WatchSnapshotsAsync(CancellationToken cancellationToken) => + new IdleSnapshotFeed().WatchAsync(cancellationToken); + } + + // Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a + // second snapshot mid-assertion. + private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed + { + /// + public async IAsyncEnumerable WatchAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + + yield break; + } + } +}