feat(audit): SecuredWrite audit kinds + best-effort per-lifecycle central direct-write; guard approve Decode (T14b)

This commit is contained in:
Joseph Doherty
2026-06-18 03:17:56 -04:00
parent c8d9303031
commit b08bfae329
4 changed files with 322 additions and 4 deletions
@@ -10,7 +10,9 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
@@ -24,6 +26,7 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
{
private readonly ISiteRepository _siteRepo;
private readonly ISecuredWriteRepository _securedWriteRepo;
private readonly IAuditLogRepository _auditRepo;
private readonly StubCommunicationService _comms;
private readonly ServiceCollection _services;
@@ -31,14 +34,44 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
{
_siteRepo = Substitute.For<ISiteRepository>();
_securedWriteRepo = Substitute.For<ISecuredWriteRepository>();
_auditRepo = Substitute.For<IAuditLogRepository>();
_comms = new StubCommunicationService();
_services = new ServiceCollection();
_services.AddScoped(_ => _siteRepo);
_services.AddScoped(_ => _securedWriteRepo);
_services.AddScoped(_ => _auditRepo);
_services.AddSingleton<CommunicationService>(_comms);
}
/// <summary>
/// Captures every <see cref="AuditEvent"/> handed to the substituted
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>. Audit emission is
/// best-effort and asynchronous off the actor thread, so a short await-condition
/// poll lets the captured list settle before assertions.
/// </summary>
private List<AuditEvent> CaptureAuditEvents()
{
var captured = new List<AuditEvent>();
_auditRepo
.When(r => r.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()))
.Do(ci => captured.Add(ci.Arg<AuditEvent>()));
return captured;
}
/// <summary>Spins briefly until the captured audit list reaches <paramref name="count"/> rows.</summary>
private static void WaitForAuditRows(List<AuditEvent> captured, int count)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (captured.Count < count && DateTime.UtcNow < deadline)
{
Thread.Sleep(10);
}
}
private static AuditEvent SingleOfKind(List<AuditEvent> captured, AuditKind kind) =>
Assert.Single(captured, e => e.Action == $"{AuditChannel.SecuredWrite}.{kind}");
/// <summary>
/// Test double for the site-write seam. <see cref="CommunicationService.WriteTagAsync"/>
/// is virtual so the approve relay can be exercised without a live actor system;
@@ -467,4 +500,159 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
Assert.Equal("Failed", updated!.Status);
Assert.Equal("unknown value type", updated.ExecutionError);
}
// ------------------------------------------------------------------------
// Audit emission (T14b — SecuredWrite AuditLog rows)
// ------------------------------------------------------------------------
/// <summary>
/// The correlation id stamped on a secured-write audit row encodes the row's long Id
/// as a big-endian value in the final 8 bytes of an otherwise-zero Guid — mirrors the
/// production encoding in <c>ManagementActor.SecuredWriteCorrelation</c>.
/// </summary>
private static Guid CorrelationFor(long id)
{
Span<byte> bytes = stackalloc byte[16];
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(bytes[8..], id);
return new Guid(bytes);
}
[Fact]
public void Submit_EmitsExactlyOneSubmitAuditRow_WithOperatorAndCorrelation()
{
SeedSiteWithConnection(1, "SITE1", "Mx1", "MxGateway");
_securedWriteRepo.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>())
.Returns(55L);
var captured = CaptureAuditEvents();
var actor = CreateActor();
var envelope = Envelope(
new SubmitSecuredWriteCommand("SITE1", "Mx1", "Tag.Setpoint", "42.5", "Double", "raise"),
"alice", "Operator");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
WaitForAuditRows(captured, 1);
var evt = SingleOfKind(captured, AuditKind.SecuredWriteSubmit);
Assert.Equal("alice", evt.Actor);
Assert.Equal(CorrelationFor(55L), evt.CorrelationId);
Assert.Equal("SecuredWrite", evt.Category);
Assert.Equal("SITE1/Mx1/Tag.Setpoint", evt.Target);
// Exactly one row — no stray duplicates.
Assert.Single(captured);
}
[Fact]
public void Reject_EmitsOneRejectAuditRow_WithVerifier()
{
SeedPendingWrite(7, operatorUser: "alice");
var captured = CaptureAuditEvents();
var actor = CreateActor();
var envelope = Envelope(new RejectSecuredWriteCommand(7, "not authorized"), "bob", "Verifier");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
WaitForAuditRows(captured, 1);
var evt = SingleOfKind(captured, AuditKind.SecuredWriteReject);
Assert.Equal("bob", evt.Actor);
Assert.Equal(CorrelationFor(7L), evt.CorrelationId);
Assert.Single(captured);
}
[Fact]
public void Approve_Success_EmitsApproveThenExecuteDeliveredAuditRows()
{
SeedPendingWrite(7, operatorUser: "alice");
ArmCasSuccess(7);
var captured = CaptureAuditEvents();
var actor = CreateActor();
var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
WaitForAuditRows(captured, 2);
var approve = SingleOfKind(captured, AuditKind.SecuredWriteApprove);
Assert.Equal("bob", approve.Actor);
Assert.Equal(CorrelationFor(7L), approve.CorrelationId);
var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute);
Assert.Equal("bob", execute.Actor);
Assert.Equal(CorrelationFor(7L), execute.CorrelationId);
// Delivered outcome → canonical Success.
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Success, execute.Outcome);
Assert.Equal(2, captured.Count);
}
[Fact]
public void Submit_AuditInsertThrows_StillSucceeds_RowStillPersisted()
{
SeedSiteWithConnection(1, "SITE1", "Mx1", "MxGateway");
PendingSecuredWrite? inserted = null;
_securedWriteRepo
.When(r => r.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
.Do(ci => inserted = ci.Arg<PendingSecuredWrite>());
_securedWriteRepo.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>())
.Returns(55L);
// Best-effort: a thrown audit insert must NOT abort the secured-write action.
_auditRepo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException(new InvalidOperationException("audit db down")));
var actor = CreateActor();
var envelope = Envelope(
new SubmitSecuredWriteCommand("SITE1", "Mx1", "Tag.A", "true", "Boolean", null),
"alice", "Operator");
actor.Tell(envelope);
// The action still succeeds and the row is still persisted despite the audit failure.
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(inserted);
Assert.Equal("Pending", inserted!.Status);
Assert.Equal("alice", inserted.OperatorUser);
}
[Fact]
public void Approve_CorruptListValueJson_FlipsStatusToFailed_DecodeError_NoRelay()
{
// C3 robustness fix: a List-typed value with corrupt JSON must not throw
// out of the handler and leave the row stuck Approved — it is contained,
// the row flips to Failed with a "value decode error", and no relay occurs.
var row = SeedPendingWrite(7, operatorUser: "alice");
row.ValueType = "List";
row.ValueJson = "{not valid json";
ArmCasSuccess(7);
var captured = CaptureAuditEvents();
PendingSecuredWrite? updated = null;
_securedWriteRepo
.When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
.Do(ci => updated = ci.Arg<PendingSecuredWrite>());
var actor = CreateActor();
var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// No relay — the value never decoded.
Assert.Equal(0, _comms.CallCount);
Assert.NotNull(updated);
Assert.Equal("Failed", updated!.Status);
Assert.NotNull(updated.ExecutedAtUtc);
Assert.StartsWith("value decode error:", updated.ExecutionError);
// The Execute audit row records the failure (canonical Failure outcome).
WaitForAuditRows(captured, 2);
var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute);
Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Failure, execute.Outcome);
}
}