feat(adminui): IAuditWriter adapter + cert-action audit-event factory

This commit is contained in:
Joseph Doherty
2026-06-19 00:31:37 -04:00
parent 21c881161a
commit 084d73ea2b
6 changed files with 254 additions and 0 deletions
@@ -0,0 +1,44 @@
using Akka.Hosting;
using Akka.TestKit.Xunit2;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Audit;
/// <summary>
/// Unit tests for <see cref="ActorAuditWriter" />, the AdminUI-side <see cref="IAuditWriter" />
/// adapter that forwards canonical audit events to the cluster-singleton
/// <see cref="AuditWriterActorKey" /> proxy resolved from the <see cref="ActorRegistry" />.
/// </summary>
public sealed class ActorAuditWriterTests : TestKit
{
/// <summary>WriteAsync forwards the event verbatim to the registered AuditWriter singleton.</summary>
[Fact]
public async Task WriteAsync_tells_the_registered_singleton()
{
var probe = CreateTestProbe("audit-writer");
var registry = new ActorRegistry();
registry.Register<AuditWriterActorKey>(probe.Ref);
var writer = new ActorAuditWriter(registry);
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "alice",
Action = "Trust",
Category = "Certificate",
SourceNode = "AABBCC",
Outcome = AuditOutcome.Success,
DetailsJson = "{}",
};
await writer.WriteAsync(evt);
var received = probe.ExpectMsg<AuditEvent>(TimeSpan.FromSeconds(3));
received.ShouldBe(evt);
}
}
@@ -0,0 +1,108 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Audit;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Audit;
/// <summary>
/// Unit tests for <see cref="CertAuditEvents" />, the pure factory that maps a certificate
/// trust/untrust/delete action into a canonical <see cref="AuditEvent" />.
/// </summary>
public sealed class CertAuditEventsTests
{
private const string Thumb = "AABBCCDDEEFF00112233445566778899AABBCCDD";
/// <summary>A successful Trust maps Category/Action/SourceNode/Actor and Success, with the
/// thumbprint carried in the details payload.</summary>
[Fact]
public void Build_trust_success_maps_core_fields()
{
var evt = CertAuditEvents.Build("Trust", "rejected→trusted", Thumb, "alice", success: true, error: null);
evt.Category.ShouldBe("Certificate");
evt.Action.ShouldBe("Trust");
evt.SourceNode.ShouldBe(Thumb);
evt.Actor.ShouldBe("alice");
evt.Outcome.ShouldBe(AuditOutcome.Success);
evt.EventId.ShouldNotBe(Guid.Empty);
evt.DetailsJson.ShouldNotBeNull();
evt.DetailsJson!.ShouldContain(Thumb);
}
/// <summary>A successful Untrust carries the Untrust action and Success outcome.</summary>
[Fact]
public void Build_untrust_success_maps_action_and_outcome()
{
var evt = CertAuditEvents.Build("Untrust", "trusted", Thumb, "bob", success: true, error: null);
evt.Action.ShouldBe("Untrust");
evt.Outcome.ShouldBe(AuditOutcome.Success);
evt.DetailsJson!.ShouldContain(Thumb);
}
/// <summary>A successful Delete carries the Delete action and Success outcome.</summary>
[Fact]
public void Build_delete_success_maps_action_and_outcome()
{
var evt = CertAuditEvents.Build("Delete", "rejected", Thumb, "carol", success: true, error: null);
evt.Action.ShouldBe("Delete");
evt.Outcome.ShouldBe(AuditOutcome.Success);
}
/// <summary>A failed Trust maps the Failure outcome and surfaces the error text in the details
/// payload.</summary>
[Fact]
public void Build_trust_failure_maps_failure_and_carries_error()
{
var evt = CertAuditEvents.Build(
"Trust", "rejected", Thumb, "alice", success: false, error: "certificate not found in rejected");
evt.Action.ShouldBe("Trust");
evt.Outcome.ShouldBe(AuditOutcome.Failure);
evt.DetailsJson!.ShouldContain("certificate not found in rejected");
}
/// <summary>A failed Delete maps the Failure outcome.</summary>
[Fact]
public void Build_delete_failure_maps_failure()
{
var evt = CertAuditEvents.Build(
"Delete", "rejected", Thumb, "dave", success: false, error: "delete failed: access denied");
evt.Action.ShouldBe("Delete");
evt.Outcome.ShouldBe(AuditOutcome.Failure);
evt.DetailsJson!.ShouldContain("delete failed: access denied");
}
/// <summary>A failed Untrust maps the Failure outcome.</summary>
[Fact]
public void Build_untrust_failure_maps_failure()
{
var evt = CertAuditEvents.Build(
"Untrust", "trusted", Thumb, "erin", success: false, error: "store write failed");
evt.Action.ShouldBe("Untrust");
evt.Outcome.ShouldBe(AuditOutcome.Failure);
evt.DetailsJson!.ShouldContain("store write failed");
}
/// <summary>On success the error text is omitted from the details payload (it serializes null).</summary>
[Fact]
public void Build_success_omits_error_text()
{
var evt = CertAuditEvents.Build("Trust", "rejected", Thumb, "alice", success: true, error: "ignored");
evt.DetailsJson!.ShouldNotContain("ignored");
}
/// <summary>The public Category constant matches the value stamped onto built events.</summary>
[Fact]
public void Category_constant_matches_built_event()
{
CertAuditEvents.Category.ShouldBe("Certificate");
CertAuditEvents.Build("Trust", "s", Thumb, "a", success: true, error: null).Category
.ShouldBe(CertAuditEvents.Category);
}
}
@@ -12,6 +12,10 @@
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<!-- Akka.TestKit.Xunit2 ties this project to xunit v2 (TestKit isn't ported to v3 yet);
used by the IAuditWriter adapter test to register a TestProbe in an ActorRegistry. -->
<PackageReference Include="Akka.Hosting"/>
<PackageReference Include="Akka.TestKit.Xunit2"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>