64e6ba70e8
P6-4: HandleListDataConnections took no user at all and returned EVERY site's connections to any authenticated caller, nullifying the row-98 fail-closed gate on HandleGetDataConnection — Get denied exactly what List handed over. DataConnectionPublicShape already scrubs secrets, so the leak was site topology plus a connection-id existence oracle, but it still defeated scoping. List now takes the AuthenticatedUser: an explicit SiteId goes through EnforceSiteScope (the ListAreas shape), and an unfiltered list from a site-scoped non-Administrator caller is narrowed to their permitted sites (the ListSites shape). GetRequiredRoles is untouched — scoping is not a role gate, so the frozen 138-command authz matrix is unaffected. The Central UI Design > Connections page bypasses the actor entirely (ISiteRepository.GetAllDataConnectionsAsync direct) and IS reachable by a scoped user: it is gated RequireDesign, and SiteScopeService explicitly does NOT exempt Designer (register row 86). It now filters sites through SiteScope.FilterSitesAsync and keeps only their connections, mirroring EventLogs/AlarmSummary. P6-5: four handlers resolved their target — and on approve/reject reported its status — BEFORE the scope check, so an out-of-scope caller could tell a real id from a fake one, and read a secured write's lifecycle status, from the error shape alone. The scope check is hoisted above the not-found and status checks in HandleSubmitSecuredWrite, HandleApproveSecuredWrite, HandleRejectSecuredWrite and HandleDebugSnapshot, with the unresolvable case routed through the existing DenyUnresolvedTargetIfScoped so the denial is identical whether or not the target exists. System-wide callers keep their ordinary not-found/status answers, and the P6-1 CAS semantics on approve/reject are unchanged (only the checks above the CAS moved). Tests: 8 new ManagementService tests + 2 CentralUI bUnit tests, all verified to FAIL against the unfixed handlers (src reverted, tests re-run) and pass after — including the unscoped/Administrator control cases proving behaviour for system-wide callers is unchanged. Full ManagementService.Tests 545 green, CentralUI.Tests 1073 green.
340 lines
14 KiB
C#
340 lines
14 KiB
C#
using System.Security.Claims;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using Bunit;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
|
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.Types.Enums;
|
|
using DataConnectionsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.DataConnections;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
|
|
|
|
/// <summary>
|
|
/// bUnit rendering tests for the Connections page (Site → DataConnection tree).
|
|
/// Focuses on the Topology-style behaviors layered onto this page: always-show-empty
|
|
/// sites, search dimming, toolbar gating, and dual-route declaration.
|
|
/// </summary>
|
|
public class DataConnectionsPageTests : BunitContext
|
|
{
|
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
|
// M9-T25: the design page now injects IConnectionHealthQueryService to render
|
|
// per-connection live health badges. A substitute defaulting to an empty map
|
|
// keeps every existing test green (no badge / unknown state when no report).
|
|
private readonly IConnectionHealthQueryService _healthQuery =
|
|
Substitute.For<IConnectionHealthQueryService>();
|
|
// M9-T24b: the page now opens a Move-to-Site dialog that dispatches the move
|
|
// through IDataConnectionMoveService (the guard-running ManagementActor path).
|
|
// Substituted so the page renders without a real ManagementActor in scope.
|
|
private readonly IDataConnectionMoveService _moveService =
|
|
Substitute.For<IDataConnectionMoveService>();
|
|
|
|
public DataConnectionsPageTests()
|
|
{
|
|
Services.AddSingleton(_siteRepo);
|
|
Services.AddSingleton(_healthQuery);
|
|
Services.AddSingleton(_moveService);
|
|
// Satisfy the page's [Inject] IDialogService — the host that actually
|
|
// renders the dialog lives in MainLayout, not in bUnit's render scope.
|
|
Services.AddScoped<IDialogService, DialogService>();
|
|
// arch-review P6-4: the page now filters its site/connection tree through
|
|
// SiteScopeService, so it must be resolvable in every render test.
|
|
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
|
AddTestAuth();
|
|
|
|
// Default: no health known. Tests that assert badge state override this.
|
|
_healthQuery.GetConnectionHealthAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyDictionary<int, ConnectionHealth>>(
|
|
new Dictionary<int, ConnectionHealth>()));
|
|
|
|
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
|
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
|
}
|
|
|
|
private void AddTestAuth()
|
|
{
|
|
var claims = new[]
|
|
{
|
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
|
new Claim(JwtTokenService.RoleClaimType, "Administrator")
|
|
};
|
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
|
Services.AddAuthorizationCore();
|
|
}
|
|
|
|
private void SeedRepos(
|
|
IEnumerable<Site>? sites = null,
|
|
IEnumerable<DataConnection>? connections = null)
|
|
{
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites?.ToList() ?? new List<Site>()));
|
|
_siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(connections?.ToList() ?? new List<DataConnection>()));
|
|
}
|
|
|
|
private static AngleSharp.Dom.IElement? FindToggleForLabel(IRenderedComponent<DataConnectionsPage> cut, string label) =>
|
|
cut.FindAll(".tv-row")
|
|
.FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label)
|
|
?.QuerySelector(".tv-toggle");
|
|
|
|
[Fact]
|
|
public void Renders_EmptyState_WhenNoSites()
|
|
{
|
|
SeedRepos();
|
|
|
|
var cut = Render<DataConnectionsPage>();
|
|
|
|
Assert.Contains("No sites configured", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void Renders_EmptySite_AsTopLevelNode()
|
|
{
|
|
// A site with no connections must still appear so it can be right-clicked
|
|
// to "Add Connection here".
|
|
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });
|
|
|
|
var cut = Render<DataConnectionsPage>();
|
|
|
|
Assert.Contains("Plant-A", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void Renders_SiteConnection_Nesting()
|
|
{
|
|
SeedRepos(
|
|
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
|
|
connections: new[]
|
|
{
|
|
new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 }
|
|
});
|
|
|
|
var cut = Render<DataConnectionsPage>();
|
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
|
|
|
Assert.Contains("PLC-1", cut.Markup);
|
|
Assert.Contains("OpcUa", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void Search_DimsNonMatches_PreservesShape()
|
|
{
|
|
SeedRepos(
|
|
sites: new[]
|
|
{
|
|
new Site("Plant-A", "plant-a") { Id = 1 },
|
|
new Site("Plant-B", "plant-b") { Id = 2 }
|
|
},
|
|
connections: new[]
|
|
{
|
|
new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 },
|
|
new DataConnection("RTU-9", "Custom", 2) { Id = 200 }
|
|
});
|
|
|
|
var cut = Render<DataConnectionsPage>();
|
|
|
|
var search = cut.Find("input[type='text']");
|
|
search.Input("Plant-A");
|
|
|
|
// Both sites remain in the DOM (shape preserved). Plant-B gets the dim style.
|
|
Assert.Contains("Plant-A", cut.Markup);
|
|
Assert.Contains("Plant-B", cut.Markup);
|
|
var dimmedNodes = cut.FindAll("span.tv-label[style*='opacity']");
|
|
Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Plant-B"));
|
|
}
|
|
|
|
[Fact]
|
|
public void AddConnectionButton_DisabledUntilSiteSelected()
|
|
{
|
|
SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });
|
|
|
|
var cut = Render<DataConnectionsPage>();
|
|
|
|
var addBtn = cut.FindAll("button")
|
|
.First(b => b.TextContent.Contains("+ Connection"));
|
|
Assert.True(addBtn.HasAttribute("disabled"));
|
|
|
|
// Click the site content (TreeView wires selection on .tv-content).
|
|
var siteContent = cut.FindAll(".tv-row")
|
|
.First(r => r.QuerySelector(".tv-label")?.TextContent == "Plant-A")
|
|
.QuerySelector(".tv-content")!;
|
|
siteContent.Click();
|
|
|
|
var addBtnAfter = cut.FindAll("button")
|
|
.First(b => b.TextContent.Contains("+ Connection"));
|
|
Assert.False(addBtnAfter.HasAttribute("disabled"));
|
|
}
|
|
|
|
[Fact]
|
|
public void DataConnectionsRoutes_AreDeclaredOnListPage()
|
|
{
|
|
// The page moved from Admin to Design; both the canonical
|
|
// /design/connections route and the /design/data-connections alias
|
|
// must resolve to the list page.
|
|
var routes = typeof(DataConnectionsPage).GetCustomAttributes(
|
|
typeof(Microsoft.AspNetCore.Components.RouteAttribute), inherit: false)
|
|
.Cast<Microsoft.AspNetCore.Components.RouteAttribute>()
|
|
.Select(a => a.Template)
|
|
.ToList();
|
|
|
|
Assert.Contains("/design/connections", routes);
|
|
Assert.Contains("/design/data-connections", routes);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectionNode_RendersLiveHealthBadge_MatchingStatus()
|
|
{
|
|
// M9-T25: a connection whose live health is Connected gets the success
|
|
// health badge next to its node; a Disconnected one gets the danger badge.
|
|
SeedRepos(
|
|
sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
|
|
connections: new[]
|
|
{
|
|
new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 },
|
|
new DataConnection("RTU-9", "Custom", 1) { Id = 200 }
|
|
});
|
|
_healthQuery.GetConnectionHealthAsync(Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyDictionary<int, ConnectionHealth>>(
|
|
new Dictionary<int, ConnectionHealth>
|
|
{
|
|
[100] = ConnectionHealth.Connected,
|
|
[200] = ConnectionHealth.Disconnected,
|
|
}));
|
|
|
|
var cut = Render<DataConnectionsPage>();
|
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
|
|
|
// Each connection node carries a status badge keyed by connection id so the
|
|
// assertion targets the right node regardless of tree ordering.
|
|
var connectedBadge = cut.Find("[data-test='conn-health-100']");
|
|
Assert.Contains("bg-success", connectedBadge.GetAttribute("class"));
|
|
Assert.Contains("Connected", connectedBadge.TextContent);
|
|
|
|
var disconnectedBadge = cut.Find("[data-test='conn-health-200']");
|
|
Assert.Contains("bg-danger", disconnectedBadge.GetAttribute("class"));
|
|
Assert.Contains("Disconnected", disconnectedBadge.TextContent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectionNode_Has_MoveToSiteAction()
|
|
{
|
|
// M9-T24b: each connection node exposes a "Move to Site…" action alongside
|
|
// Edit/Delete.
|
|
SeedRepos(
|
|
sites: new[]
|
|
{
|
|
new Site("Plant-A", "plant-a") { Id = 1 },
|
|
new Site("Plant-B", "plant-b") { Id = 2 }
|
|
},
|
|
connections: new[] { new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 } });
|
|
|
|
var cut = Render<DataConnectionsPage>();
|
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
|
|
|
Assert.Contains(cut.FindAll("button"),
|
|
b => b.TextContent.Contains("Move to Site"));
|
|
}
|
|
|
|
[Fact]
|
|
public void MoveToSite_OpensDialog_ExcludingCurrentSite()
|
|
{
|
|
// M9-T24b: clicking "Move to Site…" opens the move dialog whose target-site
|
|
// picker omits the connection's current site (Plant-A) and lists the others.
|
|
SeedRepos(
|
|
sites: new[]
|
|
{
|
|
new Site("Plant-A", "plant-a") { Id = 1 },
|
|
new Site("Plant-B", "plant-b") { Id = 2 },
|
|
new Site("Plant-C", "plant-c") { Id = 3 }
|
|
},
|
|
connections: new[] { new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 } });
|
|
|
|
// T33b: the move dialog is now opened via IDialogService.ShowAsync and rendered
|
|
// by the shared DialogHost (which lives in MainLayout in production). Render a
|
|
// host in the same DI scope so the dialog the page opens is displayed; the host's
|
|
// focus-trap JS interop runs as no-ops under loose mode.
|
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
|
var host = Render<DialogHost>();
|
|
|
|
var cut = Render<DataConnectionsPage>();
|
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
|
|
|
cut.FindAll("button").First(b => b.TextContent.Contains("Move to Site")).Click();
|
|
host.Render();
|
|
|
|
// The dialog renders with a site picker; the current site is excluded.
|
|
var dialog = host.Find(".modal.show");
|
|
var optionLabels = dialog.QuerySelectorAll("select option")
|
|
.Select(o => o.TextContent).ToList();
|
|
Assert.Contains(optionLabels, l => l.Contains("Plant-B"));
|
|
Assert.Contains(optionLabels, l => l.Contains("Plant-C"));
|
|
Assert.DoesNotContain(optionLabels, l => l.Contains("Plant-A"));
|
|
}
|
|
|
|
// ── arch-review P6-4: site scoping. This page reads ISiteRepository directly,
|
|
// bypassing ManagementActor's scope gate, and Designer is NOT exempt from
|
|
// site scope — so a scoped Designer used to see every site's connections. ──
|
|
|
|
[Fact]
|
|
public void SiteScoping_ScopedDesigner_SeesOnlyPermittedSitesAndConnections()
|
|
{
|
|
SeedRepos(
|
|
sites: new[]
|
|
{
|
|
new Site("Plant-A", "plant-a") { Id = 1 },
|
|
new Site("Plant-B", "plant-b") { Id = 2 }
|
|
},
|
|
connections: new[]
|
|
{
|
|
new DataConnection("PLC-A", "OpcUa", 1) { Id = 100 },
|
|
new DataConnection("PLC-B", "OpcUa", 2) { Id = 200 }
|
|
});
|
|
|
|
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.UsernameClaimType, "scoped"),
|
|
new Claim(JwtTokenService.RoleClaimType, "Designer"),
|
|
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant-A only
|
|
}, "TestAuth"));
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
|
|
|
|
var cut = Render<DataConnectionsPage>();
|
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
|
|
|
Assert.Contains("Plant-A", cut.Markup);
|
|
Assert.Contains("PLC-A", cut.Markup);
|
|
// Neither the out-of-scope site nor its connection may appear anywhere.
|
|
Assert.DoesNotContain("Plant-B", cut.Markup);
|
|
Assert.DoesNotContain("PLC-B", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void SiteScoping_SystemWideUser_SeesEveryConnection()
|
|
{
|
|
// The unscoped case is unchanged: the default test principal is an
|
|
// Administrator (system-wide), so the filter is a no-op.
|
|
SeedRepos(
|
|
sites: new[]
|
|
{
|
|
new Site("Plant-A", "plant-a") { Id = 1 },
|
|
new Site("Plant-B", "plant-b") { Id = 2 }
|
|
},
|
|
connections: new[]
|
|
{
|
|
new DataConnection("PLC-A", "OpcUa", 1) { Id = 100 },
|
|
new DataConnection("PLC-B", "OpcUa", 2) { Id = 200 }
|
|
});
|
|
|
|
var cut = Render<DataConnectionsPage>();
|
|
FindToggleForLabel(cut, "Plant-A")!.Click();
|
|
FindToggleForLabel(cut, "Plant-B")!.Click();
|
|
|
|
Assert.Contains("PLC-A", cut.Markup);
|
|
Assert.Contains("PLC-B", cut.Markup);
|
|
}
|
|
}
|