refactor(ui): prioritize permitted operational views and touch topology

This commit is contained in:
Joseph Doherty
2026-11-13 19:24:12 -05:00
parent 0c41b613ac
commit 9dbfe7bcaf
70 changed files with 13050 additions and 96 deletions
@@ -1,4 +1,9 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.Security;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
@@ -18,7 +23,7 @@ public class DashboardStateTests : BunitContext
/// <summary>Registers deterministic read-only dependencies and a signed-in fixture.</summary>
public DashboardStateTests()
{
this.AddAuthorization().SetAuthorized("fixture");
SetRoles("Viewer");
_sites.GetAllSitesAsync().Returns(Task.FromResult<IReadOnlyList<Site>>([new("A", "a"), new("B", "b")]));
_sites.GetAllDataConnectionsAsync().Returns(Task.FromResult<IReadOnlyList<DataConnection>>([new("Link", "OpcUa", 1)]));
_templates.GetTemplateStubsAsync().Returns(Task.FromResult<IReadOnlyList<TemplateStub>>([
@@ -26,6 +31,41 @@ public class DashboardStateTests : BunitContext
_keys.ListAsync().Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>([]));
Services.AddSingleton(_sites); Services.AddSingleton(_templates); Services.AddSingleton(_keys);
}
private void SetRoles(params string[] roles)
{
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "fixture") };
claims.AddRange(roles.Select(role => new Claim(JwtTokenService.RoleClaimType, role)));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(
new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
}
private IRenderedComponent<DashboardPage> RenderDashboard() => Render<CascadingAuthenticationState>(parameters =>
parameters.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<DashboardPage>(0);
builder.CloseComponent();
}))).FindComponent<DashboardPage>();
/// <summary>Shortcut visibility evaluates the same real policies as navigation and destination routes.</summary>
[Theory]
[InlineData("Viewer", false, true)]
[InlineData("Designer", true, false)]
[InlineData("Deployer", false, false)]
[InlineData("Administrator", false, true)]
[InlineData("Administrator,Designer,Deployer", true, true)]
[InlineData("Unmapped", false, false)]
public void Shortcuts_RespectDestinationPolicies(string role, bool templates, bool audit)
{
SetRoles(role.Split(','));
var cut = RenderDashboard();
Assert.Single(cut.FindAll("a[href='/monitoring/health']"));
Assert.Equal(templates ? 1 : 0, cut.FindAll("a[href='/design/templates']").Count);
Assert.Equal(audit ? 1 : 0, cut.FindAll("a[href='/audit/configuration']").Count);
}
/// <summary>Each read failure leaves the other facts intact and is never a false zero.</summary>
[Theory]
[InlineData("Sites configured")]
@@ -38,7 +78,7 @@ public class DashboardStateTests : BunitContext
if(label=="Data connections configured")_sites.GetAllDataConnectionsAsync().Returns(Task.FromException<IReadOnlyList<DataConnection>>(new Exception("local failure")));
if(label=="Templates")_templates.GetTemplateStubsAsync().Returns(Task.FromException<IReadOnlyList<TemplateStub>>(new Exception("local failure")));
if(label=="API keys")_keys.ListAsync().Returns(Task.FromException<IReadOnlyList<InboundApiKeyInfo>>(new Exception("local failure")));
var cut=Render<DashboardPage>();
var cut=RenderDashboard();
var facts=new Dictionary<string,string>{{"Sites configured","2"},{"Data connections configured","1"},{"Templates","3"},{"API keys","0"}};
foreach(var (name,count) in facts)
{
@@ -56,7 +96,7 @@ public class DashboardStateTests : BunitContext
public void RetryFailure_PreservesSuccessfulFactsAndCanRecoverToZero()
{
_keys.ListAsync().Returns(Task.FromException<IReadOnlyList<InboundApiKeyInfo>>(new Exception("local failure")));
var cut=Render<DashboardPage>();
var cut=RenderDashboard();
Assert.Contains("Unavailable",cut.Find("[aria-label='API keys']").TextContent);
_keys.ListAsync().Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>([]));
cut.Find("[aria-label='API keys'] button").Click();
@@ -72,7 +112,7 @@ public class DashboardStateTests : BunitContext
_sites.GetAllSitesAsync().Returns(Task.FromException<IReadOnlyList<Site>>(new Exception("local failure")),
Task.FromResult<IReadOnlyList<Site>>([new("Recovered", "recovered")]));
_sites.GetAllDataConnectionsAsync().Returns(connectionRead.Task);
var cut = Render<DashboardPage>();
var cut = RenderDashboard();
Assert.Contains("Unavailable", cut.Find("[aria-label='Sites configured']").TextContent);
Assert.Contains("Loading", cut.Find("[aria-label='Data connections configured']").TextContent);
// Model two already-dispatched retries without relying on a stale DOM node.
@@ -98,7 +138,7 @@ public class DashboardStateTests : BunitContext
{
var pending=new TaskCompletionSource<IReadOnlyList<InboundApiKeyInfo>>(TaskCreationOptions.RunContinuationsAsynchronously);
_keys.ListAsync().Returns(pending.Task);
var cut=Render<DashboardPage>();
var cut=RenderDashboard();
Assert.Contains("Loading",cut.Find("[aria-label='API keys']").TextContent);
Assert.Contains("2",cut.Find("[aria-label='Sites configured']").TextContent);
await cut.InvokeAsync(()=>pending.SetResult([]));
@@ -755,6 +755,60 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Actionable observations precede healthy entries and use stable display-name ordering.</summary>
[Fact]
public void SiteCards_PrioritizeOfflineThenWarningsAndMissingReports()
{
var now = DateTimeOffset.UtcNow;
SiteHealthState Observed(string id, bool online = true, bool stale = false) => new()
{
SiteId = id, IsOnline = online, IsMetricsStale = stale,
LastHeartbeatAt = now, LastReportReceivedAt = now,
LatestReport = ReportWithReconciler(id, true)
};
SeedStates(Observed("healthy-a"), Observed("offline-z", false), Observed("warning-b", stale: true),
Observed("offline-a", false), new SiteHealthState { SiteId = "unknown", IsOnline = true, LastHeartbeatAt = now });
var cut = RenderHealthPage();
cut.WaitForAssertion(() => Assert.Equal(
new[] { "offline-a", "offline-z", "warning-b", "unknown", "healthy-a" },
cut.FindAll("[data-site-id]").Select(e => e.GetAttribute("data-site-id")).ToArray()));
}
/// <summary>Within a priority group, display name wins over machine-id order.</summary>
[Fact]
public void SiteCards_UseDisplayNameThenStableIdentifierWithinPriority()
{
var repo = Substitute.For<ISiteRepository>();
repo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(Task.FromResult<IReadOnlyList<Site>>([
new("Zulu", "a-id"), new("Alpha", "z-id"), new("Alpha", "b-id")]));
Services.AddSingleton(repo);
SeedStates(new SiteHealthState { SiteId = "a-id", IsOnline = false },
new SiteHealthState { SiteId = "z-id", IsOnline = false },
new SiteHealthState { SiteId = "b-id", IsOnline = false });
var cut = RenderHealthPage();
cut.WaitForAssertion(() => Assert.Equal(new[] { "b-id", "z-id", "a-id" },
cut.FindAll("[data-site-id]").Select(e => e.GetAttribute("data-site-id")).ToArray()));
}
/// <summary>Heartbeat-only sites retain known liveness but never claim observed healthy metrics.</summary>
[Fact]
public void MissingReport_IsUnknownAndIntervalErrorsAreNamedHonestly()
{
SeedStates(new SiteHealthState { SiteId = "heartbeat-only", IsOnline = true, LastHeartbeatAt = DateTimeOffset.UtcNow },
new SiteHealthState { SiteId = "observed", IsOnline = true, LastHeartbeatAt = DateTimeOffset.UtcNow,
LatestReport = ReportWithReconciler("observed", true) with { ScriptErrorCount = 2 } });
var cut = RenderHealthPage();
cut.WaitForAssertion(() =>
{
var unknown = cut.Find("[data-site-id='heartbeat-only']");
Assert.Contains("Metrics unknown", unknown.TextContent);
Assert.Contains("Online", unknown.TextContent);
Assert.DoesNotContain("Offline", unknown.TextContent);
Assert.Contains("Sites with errors in last report interval", cut.Markup);
Assert.DoesNotContain("Sites with active errors", cut.Markup);
});
}
// Re-seeds the aggregator substitute with fully-specified states (as opposed
// to SeedSites' minimal online placeholders).
private void SeedStates(params SiteHealthState[] states)