feat(dashboard): settings page shows GroupToTag and UntaggedSessionVisibility
This commit is contained in:
@@ -7,4 +7,6 @@ public sealed record EffectiveDashboardConfiguration(
|
|||||||
int RecentFaultLimit,
|
int RecentFaultLimit,
|
||||||
int RecentSessionLimit,
|
int RecentSessionLimit,
|
||||||
bool ShowTagValues,
|
bool ShowTagValues,
|
||||||
IReadOnlyDictionary<string, string> GroupToRole);
|
IReadOnlyDictionary<string, string> GroupToRole,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<string>> GroupToTag,
|
||||||
|
UntaggedSessionVisibility UntaggedSessionVisibility);
|
||||||
|
|||||||
@@ -62,7 +62,15 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
|||||||
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
|
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
|
||||||
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
|
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
|
||||||
ShowTagValues: value.Dashboard.ShowTagValues,
|
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<string>; 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<string>)pair.Value,
|
||||||
|
StringComparer.OrdinalIgnoreCase),
|
||||||
|
UntaggedSessionVisibility: value.Dashboard.UntaggedSessionVisibility),
|
||||||
Protocol: new EffectiveProtocolConfiguration(
|
Protocol: new EffectiveProtocolConfiguration(
|
||||||
value.Protocol.WorkerProtocolVersion,
|
value.Protocol.WorkerProtocolVersion,
|
||||||
value.Protocol.MaxGrpcMessageBytes));
|
value.Protocol.MaxGrpcMessageBytes));
|
||||||
|
|||||||
@@ -65,6 +65,27 @@ else
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Dashboard tag mapping</th>
|
||||||
|
@* 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. *@
|
||||||
|
<td>
|
||||||
|
@if (Snapshot.Configuration.Dashboard.GroupToTag.Count == 0)
|
||||||
|
{
|
||||||
|
<span class="text-muted">(none configured)</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (KeyValuePair<string, IReadOnlyList<string>> pair in Snapshot.Configuration.Dashboard.GroupToTag)
|
||||||
|
{
|
||||||
|
<li><code>@pair.Key</code> → @string.Join(", ", pair.Value)</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
|
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
|
||||||
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
|
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
|
||||||
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
|
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
|
||||||
@@ -78,6 +99,7 @@ else
|
|||||||
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
|
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
|
||||||
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
|
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
|
||||||
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
|
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
|
||||||
|
<tr><th scope="row">Untagged session visibility</th><td>@Snapshot.Configuration.Dashboard.UntaggedSessionVisibility</td></tr>
|
||||||
<tr><th scope="row">Worker protocol</th><td>@Snapshot.Configuration.Protocol.WorkerProtocolVersion</td></tr>
|
<tr><th scope="row">Worker protocol</th><td>@Snapshot.Configuration.Protocol.WorkerProtocolVersion</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user