feat(centralui): cert-management UI + Trust action + site relay (T17)
This commit is contained in:
+144
@@ -0,0 +1,144 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
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.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using ConnectionCertificates =
|
||||
ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.ConnectionCertificates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the M7-B10 (T17) connection-certificates management page: an
|
||||
/// Administrator-gated page that resolves a data connection's owning site, lists
|
||||
/// the site's trusted-peer / rejected certificates via
|
||||
/// <see cref="ICertManagementService.ListAsync"/>, and removes a certificate via
|
||||
/// <see cref="ICertManagementService.RemoveAsync"/> (the store is node-wide per
|
||||
/// site node).
|
||||
/// </summary>
|
||||
public class ConnectionCertificatesTests : BunitContext
|
||||
{
|
||||
private readonly ICertManagementService _certs = Substitute.For<ICertManagementService>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
|
||||
private const int ConnectionId = 7;
|
||||
private const string SiteIdentifier = "plant-a";
|
||||
|
||||
public ConnectionCertificatesTests()
|
||||
{
|
||||
Services.AddSingleton(_certs);
|
||||
Services.AddSingleton(_siteRepo);
|
||||
|
||||
// The page resolves the connection → owning site so the cert relay targets
|
||||
// the right site identifier (the trusted-peer store is node-wide per site node).
|
||||
var connection = new DataConnection("PLC-OPC", "OpcUa", 1) { Id = ConnectionId };
|
||||
_siteRepo.GetDataConnectionByIdAsync(ConnectionId, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<DataConnection?>(connection));
|
||||
_siteRepo.GetSiteByIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Site?>(new Site("Plant-A", SiteIdentifier) { Id = 1 }));
|
||||
|
||||
UseRoles(Roles.Administrator);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
|
||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
private void UseRoles(params string[] roles)
|
||||
{
|
||||
Services.AddSingleton<AuthenticationStateProvider>(
|
||||
new TestAuthStateProvider(BuildPrincipal(roles)));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
}
|
||||
|
||||
private static TrustedCertInfo Cert(string thumbprint, bool rejected = false) => new(
|
||||
Thumbprint: thumbprint,
|
||||
Subject: $"CN={thumbprint}",
|
||||
Issuer: "CN=ca",
|
||||
NotBeforeUtc: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
NotAfterUtc: new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Rejected: rejected);
|
||||
|
||||
private IRenderedComponent<ConnectionCertificates> RenderPage()
|
||||
{
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<ConnectionCertificates>(0);
|
||||
builder.AddAttribute(1, nameof(ConnectionCertificates.Id), ConnectionId);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
return host.FindComponent<ConnectionCertificates>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lists_Two_Certs_From_Owning_Site()
|
||||
{
|
||||
_certs.ListAsync(SiteIdentifier, Arg.Any<CancellationToken>())
|
||||
.Returns(new CertTrustResult(true, null, new[]
|
||||
{
|
||||
Cert("AAAA1111"),
|
||||
Cert("BBBB2222", rejected: true),
|
||||
}));
|
||||
|
||||
var cut = RenderPage();
|
||||
|
||||
Assert.NotEmpty(cut.FindAll("[data-test=connection-certificates]"));
|
||||
var rows = cut.FindAll("[data-test=cert-row]");
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Contains("AAAA1111", cut.Markup);
|
||||
Assert.Contains("BBBB2222", cut.Markup);
|
||||
// List was resolved against the connection's owning site identifier.
|
||||
_certs.Received(1).ListAsync(SiteIdentifier, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_Calls_RemoveAsync_With_SiteIdentifier_And_Thumbprint()
|
||||
{
|
||||
_certs.ListAsync(SiteIdentifier, Arg.Any<CancellationToken>())
|
||||
.Returns(new CertTrustResult(true, null, new[] { Cert("AAAA1111") }));
|
||||
_certs.RemoveAsync(SiteIdentifier, "AAAA1111", Arg.Any<CancellationToken>())
|
||||
.Returns(new CertTrustResult(true, null, null));
|
||||
|
||||
var cut = RenderPage();
|
||||
cut.Find("[data-test=cert-remove-btn]").Click();
|
||||
|
||||
_certs.Received(1).RemoveAsync(SiteIdentifier, "AAAA1111", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_Store_Shows_Empty_Note()
|
||||
{
|
||||
_certs.ListAsync(SiteIdentifier, Arg.Any<CancellationToken>())
|
||||
.Returns(new CertTrustResult(true, null, Array.Empty<TrustedCertInfo>()));
|
||||
|
||||
var cut = RenderPage();
|
||||
|
||||
Assert.NotEmpty(cut.FindAll("[data-test=cert-empty]"));
|
||||
Assert.Empty(cut.FindAll("[data-test=cert-row]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void List_Failure_Shows_Load_Error()
|
||||
{
|
||||
_certs.ListAsync(SiteIdentifier, Arg.Any<CancellationToken>())
|
||||
.Returns(new CertTrustResult(false, "Site unreachable.", null));
|
||||
|
||||
var cut = RenderPage();
|
||||
|
||||
var error = cut.Find("[data-test=cert-load-error]");
|
||||
Assert.Contains("Site unreachable.", error.TextContent);
|
||||
}
|
||||
}
|
||||
+109
-6
@@ -1,10 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Forms;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
|
||||
|
||||
@@ -15,23 +19,71 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
|
||||
/// renders the typed <see cref="VerifyEndpointResult"/> — green on success, a red
|
||||
/// failure line otherwise, and a read-only certificate panel when the failure is
|
||||
/// <see cref="VerifyFailureKind.UntrustedCertificate"/> with a captured cert.
|
||||
///
|
||||
/// <para>
|
||||
/// Also covers the M7-B10 (T17) "Trust certificate" affordance on the untrusted-cert
|
||||
/// panel: an Administrator-gated button that calls
|
||||
/// <see cref="ICertManagementService.TrustAsync"/> and re-runs Verify on success.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class OpcUaEndpointVerifyTests : BunitContext
|
||||
{
|
||||
private readonly IEndpointVerificationService _verify =
|
||||
Substitute.For<IEndpointVerificationService>();
|
||||
|
||||
private readonly ICertManagementService _certs =
|
||||
Substitute.For<ICertManagementService>();
|
||||
|
||||
public OpcUaEndpointVerifyTests()
|
||||
{
|
||||
Services.AddSingleton(_verify);
|
||||
Services.AddSingleton(_certs);
|
||||
}
|
||||
|
||||
private IRenderedComponent<OpcUaEndpointEditor> RenderEditor() =>
|
||||
Render<OpcUaEndpointEditor>(p => p
|
||||
.Add(c => c.Config, new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://host:4840" })
|
||||
.Add(c => c.SiteIdentifier, "plant-a")
|
||||
.Add(c => c.ConnectionName, "PLC-OPC")
|
||||
.Add(c => c.Protocol, "OpcUa"));
|
||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
|
||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
private bool _authConfigured;
|
||||
|
||||
private void UseRoles(params string[] roles)
|
||||
{
|
||||
Services.AddSingleton<AuthenticationStateProvider>(
|
||||
new TestAuthStateProvider(BuildPrincipal(roles)));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
_authConfigured = true;
|
||||
}
|
||||
|
||||
// The editor's untrusted-cert panel hosts an AuthorizeView (RequireAdmin) around
|
||||
// the Trust button, so the editor must render inside a CascadingAuthenticationState
|
||||
// with an auth provider available. Tests that don't call UseRoles get a default
|
||||
// Designer principal (can verify + see the cert panel, but NOT the Trust button).
|
||||
private IRenderedComponent<OpcUaEndpointEditor> RenderEditor()
|
||||
{
|
||||
if (!_authConfigured)
|
||||
{
|
||||
UseRoles(Roles.Designer);
|
||||
}
|
||||
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (Microsoft.AspNetCore.Components.RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<OpcUaEndpointEditor>(0);
|
||||
builder.AddAttribute(1, nameof(OpcUaEndpointEditor.Config),
|
||||
new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://host:4840" });
|
||||
builder.AddAttribute(2, nameof(OpcUaEndpointEditor.SiteIdentifier), "plant-a");
|
||||
builder.AddAttribute(3, nameof(OpcUaEndpointEditor.ConnectionName), "PLC-OPC");
|
||||
builder.AddAttribute(4, nameof(OpcUaEndpointEditor.Protocol), "OpcUa");
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<OpcUaEndpointEditor>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_Success_ShowsSuccessMessage()
|
||||
@@ -92,4 +144,55 @@ public class OpcUaEndpointVerifyTests : BunitContext
|
||||
Assert.Empty(cut.FindAll("[data-test=verify-success]"));
|
||||
Assert.Empty(cut.FindAll("[data-test=verify-cert-panel]"));
|
||||
}
|
||||
|
||||
private static ServerCertInfo SampleCert() => new(
|
||||
Thumbprint: "ABCDEF0123456789",
|
||||
Subject: "CN=opc-server",
|
||||
Issuer: "CN=opc-server",
|
||||
NotBeforeUtc: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
NotAfterUtc: new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
DerBase64: "ZGVy");
|
||||
|
||||
private void ArrangeUntrustedVerify(ServerCertInfo cert) =>
|
||||
_verify.VerifyAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<OpcUaEndpointConfig>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new VerifyEndpointResult(
|
||||
false, VerifyFailureKind.UntrustedCertificate, "Untrusted certificate.", cert));
|
||||
|
||||
[Fact]
|
||||
public void TrustButton_Admin_SeesButton_AndClick_CallsTrustAsync()
|
||||
{
|
||||
UseRoles(Roles.Administrator);
|
||||
var cert = SampleCert();
|
||||
ArrangeUntrustedVerify(cert);
|
||||
_certs.TrustAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new CertTrustResult(true, null, null));
|
||||
|
||||
var cut = RenderEditor();
|
||||
cut.Find("[data-test=verify-endpoint-btn]").Click();
|
||||
|
||||
// Admin sees the Trust button on the untrusted-cert panel.
|
||||
var trustBtn = cut.Find("[data-test=trust-cert-btn]");
|
||||
trustBtn.Click();
|
||||
|
||||
_certs.Received(1).TrustAsync(
|
||||
"plant-a", "PLC-OPC", cert.DerBase64, cert.Thumbprint, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustButton_NonAdmin_DesignerOnly_DoesNotSeeButton()
|
||||
{
|
||||
UseRoles(Roles.Designer);
|
||||
ArrangeUntrustedVerify(SampleCert());
|
||||
|
||||
var cut = RenderEditor();
|
||||
cut.Find("[data-test=verify-endpoint-btn]").Click();
|
||||
|
||||
// The cert panel is shown, but a Designer (non-Admin) does not get the Trust button.
|
||||
Assert.NotEmpty(cut.FindAll("[data-test=verify-cert-panel]"));
|
||||
Assert.Empty(cut.FindAll("[data-test=trust-cert-btn]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ public class DataConnectionFormTests : BunitContext
|
||||
{
|
||||
Services.AddSingleton(_siteRepo);
|
||||
// The OPC UA editor rendered inside the form injects IEndpointVerificationService
|
||||
// (B8 / T17 Verify-endpoint button); a no-op substitute satisfies the injection.
|
||||
// (B8 / T17 Verify-endpoint button) and ICertManagementService (B10 / T17
|
||||
// Trust-certificate button); no-op substitutes satisfy the injections.
|
||||
Services.AddSingleton<IEndpointVerificationService>(Substitute.For<IEndpointVerificationService>());
|
||||
Services.AddSingleton<ICertManagementService>(Substitute.For<ICertManagementService>());
|
||||
AddTestAuth();
|
||||
var sites = new List<Site> { new("Plant-A", "plant-a") { Id = 1 } };
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
|
||||
@@ -14,9 +14,11 @@ public class OpcUaEndpointEditorTests : BunitContext
|
||||
public OpcUaEndpointEditorTests()
|
||||
{
|
||||
// OpcUaEndpointEditor injects IEndpointVerificationService for its Verify-endpoint
|
||||
// button (B8 / T17). These tests only exercise the form bindings, so a no-op
|
||||
// substitute satisfies the injection without driving verification.
|
||||
// button (B8 / T17) and ICertManagementService for the Trust-certificate button
|
||||
// (B10 / T17). These tests only exercise the form bindings, so no-op substitutes
|
||||
// satisfy the injections without driving verification or trust.
|
||||
Services.AddSingleton<IEndpointVerificationService>(Substitute.For<IEndpointVerificationService>());
|
||||
Services.AddSingleton<ICertManagementService>(Substitute.For<ICertManagementService>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user