feat(dashboard): settings page shows GroupToTag and UntaggedSessionVisibility

This commit is contained in:
Joseph Doherty
2026-08-17 07:15:24 -04:00
parent eff17d177c
commit 094f2ffee4
5 changed files with 334 additions and 2 deletions
@@ -0,0 +1,164 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
/// <summary>
/// Covers the projection <see cref="GatewayConfigurationProvider"/> makes from bound
/// <see cref="GatewayOptions"/> onto the effective-configuration record the dashboard renders.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class GatewayConfigurationProviderTests
{
/// <summary>The group → tag map reaches the projection with its groups, tags and ordering intact.</summary>
[Fact]
public void GetEffectiveConfiguration_CopiesGroupToTagMapping()
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(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"]);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void GetEffectiveConfiguration_KeepsGroupToTagLookupCaseInsensitive()
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwOps"] = ["team-a"],
},
},
};
EffectiveDashboardConfiguration dashboard = Project(options).Dashboard;
Assert.True(dashboard.GroupToTag.ContainsKey("gwops"));
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void GetEffectiveConfiguration_WhenNoTagsConfigured_ProjectsEmptyMapping()
{
EffectiveDashboardConfiguration dashboard = Project(new GatewayOptions()).Dashboard;
Assert.Empty(dashboard.GroupToTag);
Assert.Empty(dashboard.GroupToRole);
}
/// <summary>The untagged-session visibility policy is projected, defaulting to the fail-closed value.</summary>
[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);
}
/// <summary>The pre-existing dashboard members keep coming from the options they always did.</summary>
[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<string, string>(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"]);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void GetEffectiveConfiguration_RedactsOnlyTheSecretBearingMembers()
{
GatewayOptions options = new()
{
Ldap = new LdapOptions { ServiceAccountPassword = "bind-password" },
Dashboard = new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(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();
}
@@ -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;
/// <summary>
/// Renders <see cref="SettingsPage"/> and asserts the two SEC-25 dashboard-ACL options —
/// <c>Dashboard:GroupToTag</c> and <c>Dashboard:UntaggedSessionVisibility</c> — reach the page.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Static rendering via the framework's <see cref="HtmlRenderer"/>, the idiom used by
/// <c>SecretsNavRenderTests</c> and <c>AlarmsPageTruncationBannerTests</c> — the assertion is
/// about markup the server emits, so no component-testing dependency is warranted.
/// </para>
/// </remarks>
public sealed class SettingsPageTagVisibilityRenderTests
{
private const string EmptyMarker = "(none configured)";
/// <summary>A configured mapping renders its group and every tag, plus the visibility policy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SettingsPage_WhenTagsConfigured_RendersGroupsTagsAndVisibility()
{
string html = await RenderAsync(new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(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);
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<string> RenderAsync(DashboardOptions dashboard)
{
EffectiveGatewayConfiguration configuration =
new GatewayConfigurationProvider(Options.Create(new GatewayOptions { Dashboard = dashboard }))
.GetEffectiveConfiguration();
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService(configuration));
services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
await using ServiceProvider provider = services.BuildServiceProvider();
await using HtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
HtmlRootComponent output = await renderer.RenderComponentAsync<SettingsPage>();
return output.ToHtmlString();
});
}
// Seeds the page's first (and only) render. Everything except Configuration is inert here.
private sealed class StubSnapshotService(EffectiveGatewayConfiguration configuration)
: IDashboardSnapshotService
{
/// <inheritdoc />
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!);
/// <inheritdoc />
public IAsyncEnumerable<DashboardSnapshot> 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
{
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
yield break;
}
}
}