f5eed5e8fd
New AuditChannel.Security with AuditKind.CertTrust / CertTrustRemove (appended, ordinals stable). Both central entry points - the management actor handlers and the Central UI CertManagementService - write a best-effort ICentralAuditWriter row after the site outcome is known (Delivered when all reachable site nodes acked, Failed otherwise, incl. relay timeouts), naming actor, site, thumbprint and connection. Shared builder: CertTrustAuditEvents. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
106 lines
4.8 KiB
C#
106 lines
4.8 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
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.Tests.Services;
|
|
|
|
/// <summary>Gitea #43: the Central UI cert-trust facade records every trust decision in the central audit log.</summary>
|
|
public class CertManagementServiceAuditTests
|
|
{
|
|
private readonly ICentralAuditWriter _audit = Substitute.For<ICentralAuditWriter>();
|
|
|
|
private sealed class FakeComm(CertTrustResult? result, Exception? throws = null)
|
|
: CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
|
|
{
|
|
public override Task<CertTrustResult> TrustServerCertAsync(string siteId, TrustServerCertCommand command, CancellationToken cancellationToken = default)
|
|
=> throws is null ? Task.FromResult(result!) : Task.FromException<CertTrustResult>(throws);
|
|
|
|
public override Task<CertTrustResult> RemoveServerCertAsync(string siteId, RemoveServerCertCommand command, CancellationToken cancellationToken = default)
|
|
=> throws is null ? Task.FromResult(result!) : Task.FromException<CertTrustResult>(throws);
|
|
}
|
|
|
|
private sealed class AuthStub(ClaimsPrincipal user) : AuthenticationStateProvider
|
|
{
|
|
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(user));
|
|
}
|
|
|
|
private static ClaimsPrincipal Admin() => new(new ClaimsIdentity(
|
|
[
|
|
new Claim(JwtTokenService.UsernameClaimType, "alice"),
|
|
new Claim(JwtTokenService.RoleClaimType, Roles.Administrator),
|
|
], "test"));
|
|
|
|
private CertManagementService Sut(CommunicationService comm, ClaimsPrincipal? user = null)
|
|
=> new(comm, new AuthStub(user ?? Admin()), _audit, NullLogger<CertManagementService>.Instance);
|
|
|
|
private AuditEvent Captured() => Assert.Single(_audit.ReceivedCalls()
|
|
.Where(c => c.GetMethodInfo().Name == nameof(ICentralAuditWriter.WriteAsync))
|
|
.Select(c => (AuditEvent)c.GetArguments()[0]!));
|
|
|
|
/// <summary>A successful trust writes a Delivered CertTrust row naming the actor, site and thumbprint.</summary>
|
|
[Fact]
|
|
public async Task Trust_Success_WritesDeliveredRow()
|
|
{
|
|
var r = await Sut(new FakeComm(new CertTrustResult(true, null, null))).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
|
|
|
|
Assert.True(r.Success);
|
|
var evt = Captured();
|
|
Assert.Equal("alice", evt.Actor);
|
|
Assert.Equal("SITE1/AB12", evt.Target);
|
|
Assert.Contains("\"kind\":\"CertTrust\"", evt.DetailsJson);
|
|
Assert.Contains("\"status\":\"Delivered\"", evt.DetailsJson);
|
|
}
|
|
|
|
/// <summary>A failed relay is audited as Failed with the error, and the page still gets its inline result.</summary>
|
|
[Fact]
|
|
public async Task Trust_RelayThrows_WritesFailedRow_AndReturnsInlineError()
|
|
{
|
|
var r = await Sut(new FakeComm(null, new TimeoutException("site unreachable"))).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
|
|
|
|
Assert.False(r.Success);
|
|
var evt = Captured();
|
|
Assert.Contains("\"status\":\"Failed\"", evt.DetailsJson);
|
|
Assert.Contains("site unreachable", evt.DetailsJson);
|
|
}
|
|
|
|
/// <summary>A removal writes a CertTrustRemove row.</summary>
|
|
[Fact]
|
|
public async Task Remove_Success_WritesRemoveRow()
|
|
{
|
|
await Sut(new FakeComm(new CertTrustResult(true, null, null))).RemoveAsync("SITE1", "AB12");
|
|
|
|
Assert.Contains("\"kind\":\"CertTrustRemove\"", Captured().DetailsJson);
|
|
}
|
|
|
|
/// <summary>An unauthorized caller never reaches the site and writes no audit row.</summary>
|
|
[Fact]
|
|
public async Task Trust_Unauthorized_WritesNothing()
|
|
{
|
|
var viewer = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtTokenService.UsernameClaimType, "bob")], "test"));
|
|
|
|
var r = await Sut(new FakeComm(new CertTrustResult(true, null, null)), viewer).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
|
|
|
|
Assert.False(r.Success);
|
|
Assert.Empty(_audit.ReceivedCalls());
|
|
}
|
|
|
|
/// <summary>An audit-writer failure never changes the trust result.</summary>
|
|
[Fact]
|
|
public async Task Trust_AuditWriterThrows_ResultUnaffected()
|
|
{
|
|
_audit.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()).Returns(Task.FromException(new InvalidOperationException("db down")));
|
|
|
|
var r = await Sut(new FakeComm(new CertTrustResult(true, null, null))).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
|
|
|
|
Assert.True(r.Success);
|
|
}
|
|
}
|