feat(centralui): cert-management UI + Trust action + site relay (T17)
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@inject IEndpointVerificationService VerificationService
|
||||
@inject ICertManagementService CertManagementService
|
||||
|
||||
<div class="opcua-endpoint-editor">
|
||||
<h6 class="text-muted border-bottom pb-1">@Title</h6>
|
||||
@@ -91,9 +93,44 @@
|
||||
<dt class="col-sm-3">Not after</dt>
|
||||
<dd class="col-sm-9">@cert.NotAfterUtc.ToString("u")</dd>
|
||||
</dl>
|
||||
<div class="text-muted fst-italic">
|
||||
Use cert management to trust this certificate.
|
||||
</div>
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized>
|
||||
<button type="button" class="btn btn-outline-warning btn-sm mt-1"
|
||||
data-test="trust-cert-btn"
|
||||
disabled="@_trusting"
|
||||
@onclick="() => TrustCert(cert)">
|
||||
@if (_trusting)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
<span>Trusting…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Trust certificate</span>
|
||||
}
|
||||
</button>
|
||||
<div class="text-muted fst-italic mt-1">
|
||||
Trusting adds this certificate to every node of the site's
|
||||
trusted-peer store (node-wide), then re-runs Verify.
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="text-muted fst-italic">
|
||||
An Administrator must trust this certificate (cert management).
|
||||
</div>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@if (_trustError is { } trustError)
|
||||
{
|
||||
<div class="text-danger small mt-1" data-test="trust-cert-error">@trustError</div>
|
||||
}
|
||||
@if (_trustSucceeded)
|
||||
{
|
||||
<div class="text-success small mt-1" data-test="trust-cert-success">
|
||||
✓ Certificate trusted.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -322,11 +359,25 @@
|
||||
|
||||
private bool _verifying;
|
||||
private VerifyEndpointResult? _verifyResult;
|
||||
private bool _trusting;
|
||||
private bool _trustSucceeded;
|
||||
private string? _trustError;
|
||||
|
||||
private async Task VerifyEndpoint()
|
||||
private Task VerifyEndpoint() => VerifyEndpoint(clearTrustNotes: true);
|
||||
|
||||
private async Task VerifyEndpoint(bool clearTrustNotes)
|
||||
{
|
||||
_verifying = true;
|
||||
_verifyResult = null;
|
||||
// A fresh (user-initiated) verify supersedes any prior trust outcome — clear
|
||||
// the inline notes so a stale "trusted" / error banner doesn't linger across
|
||||
// probes. The trust-triggered re-verify keeps the success note so the operator
|
||||
// still sees confirmation even if the re-probe also surfaces a (different) cert.
|
||||
if (clearTrustNotes)
|
||||
{
|
||||
_trustSucceeded = false;
|
||||
_trustError = null;
|
||||
}
|
||||
try
|
||||
{
|
||||
_verifyResult = await VerificationService.VerifyAsync(
|
||||
@@ -343,6 +394,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
// M7 T17: trust the captured untrusted server certificate at every node of the
|
||||
// owning site, then re-run Verify (which should now succeed and clear the cert
|
||||
// panel). Administrator-gated by the AuthorizeView wrapping the button; the
|
||||
// CertManagementService enforces the same role server-side-of-the-trust-boundary.
|
||||
private async Task TrustCert(ServerCertInfo cert)
|
||||
{
|
||||
_trusting = true;
|
||||
_trustError = null;
|
||||
_trustSucceeded = false;
|
||||
try
|
||||
{
|
||||
var result = await CertManagementService.TrustAsync(
|
||||
SiteIdentifier, ConnectionName, cert.DerBase64, cert.Thumbprint);
|
||||
if (result.Success)
|
||||
{
|
||||
_trustSucceeded = true;
|
||||
// Re-run Verify so the editor reflects the now-trusted state (and
|
||||
// clears the cert panel on the expected success). Preserve the
|
||||
// success note (clearTrustNotes:false) so confirmation is shown.
|
||||
await VerifyEndpoint(clearTrustNotes: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_trustError = result.Error ?? "Failed to trust certificate.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trustError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_trusting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnableHeartbeat() =>
|
||||
Config.Heartbeat = new OpcUaHeartbeatConfig();
|
||||
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
@page "/design/connections/{Id:int}/certificates"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@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.CentralUI.Services
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ICertManagementService CertManagementService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3" data-test="connection-certificates">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back</button>
|
||||
<h4 class="mb-0">Server Certificates@(string.IsNullOrEmpty(_connectionName) ? "" : $" — {_connectionName}")</h4>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info py-2 small">
|
||||
The trusted-peer certificate store is <strong>node-wide for the site</strong>
|
||||
(shared by every site node), not per data connection. Trusting or removing a
|
||||
certificate affects all OPC UA connections at this site.
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_loadError is { } loadError)
|
||||
{
|
||||
<div class="text-danger small" data-test="cert-load-error">@loadError</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_actionError is { } actionError)
|
||||
{
|
||||
<div class="text-danger small mb-2" data-test="cert-action-error">@actionError</div>
|
||||
}
|
||||
|
||||
@if (_certs.Count == 0)
|
||||
{
|
||||
<div class="text-muted small" data-test="cert-empty">
|
||||
No certificates are present in this site's trusted-peer or rejected stores.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subject</th>
|
||||
<th>Issuer</th>
|
||||
<th>Thumbprint</th>
|
||||
<th>Not before</th>
|
||||
<th>Not after</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var cert in _certs)
|
||||
{
|
||||
<tr data-test="cert-row">
|
||||
<td class="small"><code>@cert.Subject</code></td>
|
||||
<td class="small"><code>@cert.Issuer</code></td>
|
||||
<td class="small"><code>@cert.Thumbprint</code></td>
|
||||
<td class="small">@cert.NotBeforeUtc.ToString("u")</td>
|
||||
<td class="small">@cert.NotAfterUtc.ToString("u")</td>
|
||||
<td class="small">
|
||||
@if (cert.Rejected)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Rejected</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success">Trusted</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
data-test="cert-remove-btn"
|
||||
disabled="@_removing"
|
||||
@onclick="() => RemoveCert(cert.Thumbprint)">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private bool _removing;
|
||||
private string? _loadError;
|
||||
private string? _actionError;
|
||||
private string _connectionName = string.Empty;
|
||||
private string _siteIdentifier = string.Empty;
|
||||
private IReadOnlyList<TrustedCertInfo> _certs = Array.Empty<TrustedCertInfo>();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Resolve the connection's owning site so the cert relay targets the
|
||||
// right site (the trusted-peer store is node-wide PER SITE node).
|
||||
var connection = await SiteRepository.GetDataConnectionByIdAsync(Id);
|
||||
if (connection is null)
|
||||
{
|
||||
_loadError = $"Data connection {Id} not found.";
|
||||
return;
|
||||
}
|
||||
|
||||
_connectionName = connection.Name;
|
||||
var site = await SiteRepository.GetSiteByIdAsync(connection.SiteId);
|
||||
if (site is null)
|
||||
{
|
||||
_loadError = $"Site {connection.SiteId} not found.";
|
||||
return;
|
||||
}
|
||||
|
||||
_siteIdentifier = site.SiteIdentifier;
|
||||
await LoadCertsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loadError = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadCertsAsync()
|
||||
{
|
||||
var result = await CertManagementService.ListAsync(_siteIdentifier);
|
||||
if (result.Success)
|
||||
{
|
||||
_certs = result.Certs ?? Array.Empty<TrustedCertInfo>();
|
||||
_loadError = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_loadError = result.Error ?? "Failed to list certificates.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveCert(string thumbprint)
|
||||
{
|
||||
_removing = true;
|
||||
_actionError = null;
|
||||
try
|
||||
{
|
||||
var result = await CertManagementService.RemoveAsync(_siteIdentifier, thumbprint);
|
||||
if (result.Success)
|
||||
{
|
||||
// Re-list so the table reflects the removal (the store is node-wide,
|
||||
// so the authoritative list comes back from the site, not local state).
|
||||
await LoadCertsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_actionError = result.Error ?? "Failed to remove certificate.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_actionError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_removing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack() =>
|
||||
NavigationManager.NavigateTo($"/design/connections/{Id}/edit");
|
||||
}
|
||||
@@ -72,6 +72,21 @@
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
</div>
|
||||
|
||||
@if (Id.HasValue && _protocol == "OpcUa")
|
||||
{
|
||||
<div class="mb-2">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
data-test="manage-certificates-link"
|
||||
href="@($"/design/connections/{Id}/certificates")">
|
||||
Manage certificates
|
||||
</a>
|
||||
<div class="form-text">
|
||||
View, trust, and remove OPC UA server certificates in the
|
||||
site's node-wide trusted-peer store.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<h6 class="text-muted mt-3">Primary endpoint</h6>
|
||||
@if (_protocol == "MxGateway")
|
||||
{
|
||||
|
||||
@@ -78,6 +78,14 @@ public static class ServiceCollectionExtensions
|
||||
// on the OPC UA endpoint editor (read-only connect probe, never trusts certs).
|
||||
services.AddScoped<IEndpointVerificationService, EndpointVerificationService>();
|
||||
|
||||
// OPC UA Cert Management (M7 T17 / D6): facade over the three
|
||||
// CommunicationService cert-trust relay methods. Enforces the CentralUI-side
|
||||
// role trust boundary (D7: Trust + Remove require Administrator, List requires
|
||||
// Designer) and translates transport failures into typed CertTrustResults.
|
||||
// Backs the "Trust certificate" button on the OPC UA endpoint editor and the
|
||||
// connection-certificates management page (node-wide site PKI store).
|
||||
services.AddScoped<ICertManagementService, CertManagementService>();
|
||||
|
||||
// Test Bindings: facade over CommunicationService.ReadTagValuesAsync —
|
||||
// same Design-role guard + typed-failure translation as the browse
|
||||
// service. Backs the Test Bindings dialog on the Configure Instance
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="ICertManagementService"/> implementation — a thin facade over
|
||||
/// the three <see cref="CommunicationService"/> cert-trust relay methods that enforces
|
||||
/// the CentralUI-side role trust boundary (Decision D7: Trust + Remove require
|
||||
/// <c>Administrator</c>, List requires <c>Designer</c>), and translates transport
|
||||
/// exceptions into a typed <see cref="CertTrustResult"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Site-side actors (<c>SiteCommunicationActor</c> + <c>DeploymentManagerActor</c>) do
|
||||
/// not unwrap the central trust envelope, so the role check MUST run here — never on
|
||||
/// the site (mirrors <see cref="BrowseService"/> and <see cref="EndpointVerificationService"/>).
|
||||
/// On an unauthorized caller the method returns a non-success
|
||||
/// <see cref="CertTrustResult"/> with <c>"Not authorized."</c> rather than throwing;
|
||||
/// transport failures (timeouts, unreachable sites) collapse into a non-success result
|
||||
/// so the editor / page can render an inline outcome.
|
||||
/// </remarks>
|
||||
public sealed class CertManagementService : ICertManagementService
|
||||
{
|
||||
private readonly CommunicationService _communication;
|
||||
private readonly AuthenticationStateProvider _auth;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CertManagementService"/>.
|
||||
/// </summary>
|
||||
/// <param name="communication">Central-side cluster communication service.</param>
|
||||
/// <param name="auth">Authentication state provider used for the role guards.</param>
|
||||
public CertManagementService(CommunicationService communication, AuthenticationStateProvider auth)
|
||||
{
|
||||
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
|
||||
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CertTrustResult> TrustAsync(
|
||||
string siteIdentifier,
|
||||
string connectionName,
|
||||
string derBase64,
|
||||
string thumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// D7: trusting a server certificate mutates every site node's PKI store, so
|
||||
// it is an Administrator-only action. The site does not enforce envelope-level
|
||||
// roles, so this check must happen here before any cross-cluster traffic.
|
||||
if (!await HasRoleAsync(Roles.Administrator))
|
||||
{
|
||||
return new CertTrustResult(false, "Not authorized.", null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _communication.TrustServerCertAsync(
|
||||
siteIdentifier,
|
||||
new TrustServerCertCommand(connectionName, derBase64, thumbprint),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
return new CertTrustResult(false, ex.Message, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Caller-initiated cancel — propagate so Blazor can drop the response
|
||||
// cleanly. Distinct from Timeout (which the UI renders inline).
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CertTrustResult(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CertTrustResult> ListAsync(
|
||||
string siteIdentifier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// D7: listing trusted certs is read-only, so the lower Designer bar applies
|
||||
// (an Administrator also satisfies this because admins hold every role claim
|
||||
// by convention). Same CentralUI-side guard rationale as TrustAsync.
|
||||
if (!await HasRoleAsync(Roles.Designer))
|
||||
{
|
||||
return new CertTrustResult(false, "Not authorized.", null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _communication.ListServerCertsAsync(
|
||||
siteIdentifier, new ListServerCertsCommand(), cancellationToken);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
return new CertTrustResult(false, ex.Message, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CertTrustResult(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CertTrustResult> RemoveAsync(
|
||||
string siteIdentifier,
|
||||
string thumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// D7: removing trust mutates every site node's PKI store, so it is an
|
||||
// Administrator-only action — same gate as TrustAsync.
|
||||
if (!await HasRoleAsync(Roles.Administrator))
|
||||
{
|
||||
return new CertTrustResult(false, "Not authorized.", null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _communication.RemoveServerCertAsync(
|
||||
siteIdentifier, new RemoveServerCertCommand(thumbprint), cancellationToken);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
return new CertTrustResult(false, ex.Message, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CertTrustResult(false, ex.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> HasRoleAsync(string role)
|
||||
{
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
return state.User.HasClaim(JwtTokenService.RoleClaimType, role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI facade over the central-to-site OPC UA server-certificate trust
|
||||
/// commands (T17 / D6). Backs the "Trust certificate" affordance on the OPC UA
|
||||
/// endpoint editor and the dedicated connection-certificates management page: it
|
||||
/// forwards <see cref="TrustServerCertCommand"/> / <see cref="ListServerCertsCommand"/>
|
||||
/// / <see cref="RemoveServerCertCommand"/> to the owning site via
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService"/>, which routes
|
||||
/// them to the site Deployment Manager singleton.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The service is the trust boundary for the cert-management capability: site-side
|
||||
/// actors do not unwrap the central trust envelope, so the role check MUST run here
|
||||
/// before any cross-cluster traffic is generated (mirrors <see cref="IBrowseService"/>
|
||||
/// and <see cref="IEndpointVerificationService"/>). Per Decision D7: Trust + Remove
|
||||
/// require the <c>Administrator</c> role; List requires the <c>Designer</c> role.
|
||||
/// Transport failures (timeouts, unreachable sites) are translated into a typed
|
||||
/// <see cref="CertTrustResult"/> so callers can render an inline outcome rather than
|
||||
/// throwing.
|
||||
///
|
||||
/// <para>
|
||||
/// The trusted-peer store is NODE-WIDE per site node (not per connection); a
|
||||
/// trust/remove decision is broadcast by the site Deployment Manager to every site
|
||||
/// node's <c>CertStoreActor</c>, and a list answers from the singleton's own node.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ICertManagementService
|
||||
{
|
||||
/// <summary>
|
||||
/// Trusts an OPC UA server certificate at every node of the owning site
|
||||
/// (Administrator-gated). The captured DER bytes are written into each node's
|
||||
/// trusted-peer PKI store under the thumbprint filename key.
|
||||
/// </summary>
|
||||
/// <param name="siteIdentifier">The target site identifier (the machine-readable <c>SiteIdentifier</c> used in Akka addresses, NOT the numeric primary key).</param>
|
||||
/// <param name="connectionName">The data connection the certificate was captured from (diagnostics / correlation only).</param>
|
||||
/// <param name="derBase64">The server certificate's DER encoding, base64-encoded.</param>
|
||||
/// <param name="thumbprint">The certificate thumbprint — used as the store filename key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to a <see cref="CertTrustResult"/> — success, or a classified failure (unauthorized / timeout / transport error).</returns>
|
||||
Task<CertTrustResult> TrustAsync(
|
||||
string siteIdentifier,
|
||||
string connectionName,
|
||||
string derBase64,
|
||||
string thumbprint,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists the certificates currently present in the owning site's trusted-peer
|
||||
/// and rejected PKI stores (Designer-gated). Answered from the site Deployment
|
||||
/// Manager singleton's own node.
|
||||
/// </summary>
|
||||
/// <param name="siteIdentifier">The target site identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to a <see cref="CertTrustResult"/> carrying the listed certificates on success.</returns>
|
||||
Task<CertTrustResult> ListAsync(
|
||||
string siteIdentifier,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously-trusted OPC UA server certificate from every node of
|
||||
/// the owning site (Administrator-gated), identified by thumbprint.
|
||||
/// </summary>
|
||||
/// <param name="siteIdentifier">The target site identifier.</param>
|
||||
/// <param name="thumbprint">The thumbprint of the certificate to remove.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to a <see cref="CertTrustResult"/> — success, or a classified failure.</returns>
|
||||
Task<CertTrustResult> RemoveAsync(
|
||||
string siteIdentifier,
|
||||
string thumbprint,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -161,6 +161,18 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
// children holding the live OPC UA sessions.
|
||||
Receive<ReadTagValuesCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// OPC UA server-certificate trust management (T17 / D6) — forward to the
|
||||
// Deployment Manager singleton, which owns the cross-node trust broadcast.
|
||||
// The trusted-peer PKI store is node-wide per site node, so a trust/remove
|
||||
// decision must reach BOTH nodes' CertStoreActor; the singleton broadcasts
|
||||
// to every site node (list answers from the singleton's own node). The
|
||||
// singleton always lands on the active node, the same routing rationale as
|
||||
// BrowseNodeCommand above. Forward preserves the central Ask sender so the
|
||||
// CertTrustResult routes straight back to the waiting Ask.
|
||||
Receive<TrustServerCertCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<ListServerCertsCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
Receive<RemoveServerCertCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// Pattern 7: Remote Queries
|
||||
Receive<EventLogQueryRequest>(msg =>
|
||||
{
|
||||
|
||||
@@ -416,6 +416,73 @@ public class CommunicationService
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
// ── OPC UA Server-Certificate Trust (T17 / D6 — node-wide site PKI store) ──
|
||||
|
||||
/// <summary>
|
||||
/// Asks the owning site to trust an OPC UA server certificate at every site
|
||||
/// node. Backs the CentralUI cert-management Trust action. The site Deployment
|
||||
/// Manager singleton broadcasts the captured DER bytes to every site node's
|
||||
/// <c>CertStoreActor</c>; the result reflects whether every reachable node
|
||||
/// acked. The Ask is bounded by <see cref="CommunicationOptions.QueryTimeout"/>,
|
||||
/// mirroring <see cref="BrowseNodeAsync"/> and the other interactive
|
||||
/// design-time site queries.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The target site identifier.</param>
|
||||
/// <param name="command">The trust-server-cert command (connection name + DER + thumbprint).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The cert-trust result (per-node aggregate success + first error).</returns>
|
||||
public Task<CertTrustResult> TrustServerCertAsync(
|
||||
string siteId,
|
||||
TrustServerCertCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new SiteEnvelope(siteId, command);
|
||||
return GetActor().Ask<CertTrustResult>(
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the owning site to list the certificates in its trusted-peer and
|
||||
/// rejected PKI stores. Backs the CentralUI cert-management list page. Answered
|
||||
/// by the site Deployment Manager singleton from its own node (the store is
|
||||
/// node-wide per site node). The Ask is bounded by
|
||||
/// <see cref="CommunicationOptions.QueryTimeout"/>.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The target site identifier.</param>
|
||||
/// <param name="command">The list-server-certs command.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The cert-trust result carrying the listed certificates on success.</returns>
|
||||
public Task<CertTrustResult> ListServerCertsAsync(
|
||||
string siteId,
|
||||
ListServerCertsCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new SiteEnvelope(siteId, command);
|
||||
return GetActor().Ask<CertTrustResult>(
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the owning site to remove a previously-trusted OPC UA server
|
||||
/// certificate from every site node, identified by thumbprint. Backs the
|
||||
/// CentralUI cert-management Remove action. The site Deployment Manager
|
||||
/// singleton broadcasts the removal to every site node's <c>CertStoreActor</c>.
|
||||
/// The Ask is bounded by <see cref="CommunicationOptions.QueryTimeout"/>.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The target site identifier.</param>
|
||||
/// <param name="command">The remove-server-cert command (thumbprint).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The cert-trust result (per-node aggregate success + first error).</returns>
|
||||
public Task<CertTrustResult> RemoveServerCertAsync(
|
||||
string siteId,
|
||||
RemoveServerCertCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new SiteEnvelope(siteId, command);
|
||||
return GetActor().Ask<CertTrustResult>(
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
// ── Test Bindings (one-shot live read of bound tags) ──
|
||||
|
||||
/// <summary>
|
||||
|
||||
+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