199 lines
8.2 KiB
C#
199 lines
8.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Covers the M7-B8 (T17) "Verify endpoint" affordance added to
|
|
/// <see cref="OpcUaEndpointEditor"/>: a button that calls the injected
|
|
/// <see cref="IEndpointVerificationService"/> with the current endpoint config and
|
|
/// 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 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()
|
|
{
|
|
_verify.VerifyAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
|
Arg.Any<OpcUaEndpointConfig>(), Arg.Any<CancellationToken>())
|
|
.Returns(new VerifyEndpointResult(true, null, null, null));
|
|
|
|
var cut = RenderEditor();
|
|
cut.Find("[data-test=verify-endpoint-btn]").Click();
|
|
|
|
Assert.NotEmpty(cut.FindAll("[data-test=verify-success]"));
|
|
Assert.Empty(cut.FindAll("[data-test=verify-failure]"));
|
|
Assert.Empty(cut.FindAll("[data-test=verify-cert-panel]"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Verify_UntrustedCertificate_ShowsCertPanelWithThumbprint()
|
|
{
|
|
var cert = new ServerCertInfo(
|
|
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");
|
|
_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));
|
|
|
|
var cut = RenderEditor();
|
|
cut.Find("[data-test=verify-endpoint-btn]").Click();
|
|
|
|
var panel = cut.Find("[data-test=verify-cert-panel]");
|
|
Assert.Contains("ABCDEF0123456789", panel.TextContent);
|
|
Assert.Contains("CN=opc-server", panel.TextContent);
|
|
// The failure line is still shown alongside the cert panel.
|
|
Assert.NotEmpty(cut.FindAll("[data-test=verify-failure]"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Verify_GenericFailure_ShowsFailureMessage()
|
|
{
|
|
_verify.VerifyAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
|
Arg.Any<OpcUaEndpointConfig>(), Arg.Any<CancellationToken>())
|
|
.Returns(new VerifyEndpointResult(
|
|
false, VerifyFailureKind.Unreachable, "Connection refused.", null));
|
|
|
|
var cut = RenderEditor();
|
|
cut.Find("[data-test=verify-endpoint-btn]").Click();
|
|
|
|
var failure = cut.Find("[data-test=verify-failure]");
|
|
Assert.Contains("Connection refused.", failure.TextContent);
|
|
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]"));
|
|
}
|
|
}
|