Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryVirtualizeTests.cs
T
Joseph Doherty 042e2efd1d fix(centralui): enforce site scoping on AlarmSummary — filter site list + deny out-of-scope alarm fetches, mirroring DebugView (arch-review 2026-08-19 P6-2 High)
AlarmSummary.razor was gated RequireDeployment but never consulted
SiteScopeService: the site picker listed every site unfiltered and
GetSiteAlarmsAsync ran for whatever siteId the onchange event carried,
so a Deployer scoped to one site could watch any site's live alarms
(including CurrentValue/LimitValue process data). The page was missed
by the ad0100f2 scoping survey.

Fix mirrors EventLogs.razor/DebugView.razor: OnInitializedAsync filters
_sites through SiteScope.FilterSitesAsync, and both the site-change
handler and RefreshAsync (the shared fetch path used by the manual
refresh button and the 15s poll timer) independently re-check the
selected site against the filtered list before ever calling
AlarmSummaryService, denying/resetting an out-of-scope id rather than
merely hiding it from the dropdown.

Tests: bUnit render tests added to AlarmSummaryRenderTests.cs proving
(1) a scoped Deployer's picker omits the out-of-scope site and (2) a
forged out-of-scope onchange value is denied — no row renders and
GetSiteAlarmsAsync is never called for that site. AlarmSummaryVirtualizeTests.cs
updated to register the now-required SiteScopeService.
2026-08-19 13:54:45 -04:00

117 lines
4.6 KiB
C#

using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using AlarmSummaryPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.AlarmSummary;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>
/// Covers the Alarm Summary flat table's two rendering paths (arch-review WP2.4). A short
/// list renders every row with a plain foreach; past the threshold the same row template is
/// handed to <c>Virtualize</c> so a site with thousands of alarms stops materialising the
/// whole table into the circuit's render tree on every delta. Row markup — including the
/// <c>data-test</c> hook the operator tests key off — is identical either way.
/// </summary>
public class AlarmSummaryVirtualizeTests : BunitContext
{
private readonly IAlarmSummaryService _summary = Substitute.For<IAlarmSummaryService>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private void Arrange(int rowCount)
{
JSInterop.Mode = JSRuntimeMode.Loose;
var rows = Enumerable.Range(0, rowCount)
.Select(i => new AlarmSummaryRow(
$"inst-{i:D4}",
new AlarmStateChanged($"inst-{i:D4}", $"alarm-{i:D4}", AlarmState.Active, i, DateTimeOffset.UtcNow)))
.ToList();
_summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new AlarmSummaryResult(rows, Array.Empty<string>())));
_summary.ComputeRollup(Arg.Any<IReadOnlyList<AlarmSummaryRow>>())
.Returns(new AlarmRollup(rowCount, rowCount, 0, new Dictionary<AlarmKind, int>()));
Services.AddSingleton(_summary);
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Site 1", "site1") { Id = 1 },
}));
Services.AddSingleton(_siteRepo);
Services.AddSingleton<ISiteAlarmLiveCache>(new InertLiveCache());
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Administrator"),
};
Services.AddSingleton<AuthenticationStateProvider>(
new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))));
Services.AddAuthorizationCore();
}
private IRenderedComponent<AlarmSummaryPage> RenderWithSiteSelected()
{
var cut = Render<AlarmSummaryPage>();
cut.Find("[data-test='alarm-summary-site']").Change("1");
return cut;
}
[Fact]
public void ShortList_RendersEveryRowInline()
{
Arrange(rowCount: 20);
var cut = RenderWithSiteSelected();
cut.WaitForAssertion(() =>
Assert.Equal(20, cut.FindAll("tr[data-test='alarm-summary-row']").Count));
// The row-count line is unchanged by either path.
Assert.Contains("Showing 20 of 20", cut.Markup);
}
[Fact]
public void LongList_VirtualizesWithoutRenderingEveryRow()
{
Arrange(rowCount: 2000);
var cut = RenderWithSiteSelected();
cut.WaitForAssertion(() => Assert.Contains("Showing 2000 of 2000", cut.Markup));
var rendered = cut.FindAll("tr[data-test='alarm-summary-row']").Count;
Assert.InRange(rendered, 1, 1999);
// Rows still carry the same shape — instance name, alarm name, severity.
Assert.Contains("alarm-", cut.Markup);
}
/// <summary>Never goes live, so the page keeps its poll snapshot for these tests.</summary>
private sealed class InertLiveCache : ISiteAlarmLiveCache
{
public IDisposable Subscribe(int siteId, Action onChanged) => new NoOp();
public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) =>
Array.Empty<AlarmStateChanged>();
public bool IsLive(int siteId) => false;
public IReadOnlyList<string> GetNotReportingInstances(int siteId) => Array.Empty<string>();
private sealed class NoOp : IDisposable
{
public void Dispose() { }
}
}
}