Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs
T
Joseph Doherty 933dd1a874 feat(audit): OtOpcUa adopt canonical ZB.MOM.WW.Audit.AuditEvent + AuditWriterActor:IAuditWriter + Outcome derivation (Task 2.1)
Deep-adopt the shared audit record. Deletes the bespoke 8-field positional
Commons AuditEvent and repoints the writer path at ZB.MOM.WW.Audit.AuditEvent
(0.1.0, feed-mapped via dohertj2-gitea). Adds the package reference to both
Commons and ControlPlane.

- AuditWriterActor now implements IAuditWriter: WriteAsync(evt, ct) is a
  best-effort, never-throwing entry point that Self.Tell()s the event onto the
  same batching/dedup/flush pipeline and returns Task.CompletedTask. Existing
  Receive<AuditEvent> + 500/5s batching + two-layer dedup unchanged.
- Flush mapping updated for the canonical field types: OccurredAtUtc is now
  DateTimeOffset (.UtcDateTime into the datetime2 column), SourceNode is string?
  (was NodeId.Value), CorrelationId is Guid? (stored null when null). Outcome is
  NOT yet persisted (column lands in Task 2.2).
- New AuditOutcomeMapper.FromAction maps the OtOpcUa action vocabulary to the
  required canonical Outcome: OpcUaAccessDenied / CrossClusterNamespaceAttempt ->
  Denied; config verbs (DraftCreated/Edited, Published, RolledBack, NodeApplied,
  ClusterCreated, NodeAdded, CredentialAdded/Disabled, ExternalIdReleased) ->
  Success. OtOpcUa emits no Failure events.

The Akka message shape changed, but the structured audit path is dormant (zero
production emit/Tell sites; all live audit flows through the bespoke SP path),
so there is no rolling-deploy wire-compat concern. Tested-not-exercised by
design.

ControlPlane.Tests: 44/44 green (AuditWriterActor suite rewritten to construct
the canonical record + assert the Outcome derivation table + the WriteAsync
best-effort/mailbox-routing contract + null SourceNode/CorrelationId handling).
2026-06-02 09:53:12 -04:00

195 lines
7.6 KiB
C#

using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
{
private static AuditEvent NewEvent(Guid eventId, string action = "Edit", string actor = "joe") =>
new()
{
EventId = eventId,
Category = "Config",
Action = action,
Actor = actor,
OccurredAtUtc = DateTimeOffset.UtcNow,
Outcome = AuditOutcomeMapper.FromAction(action),
DetailsJson = "{\"field\":\"value\"}",
SourceNode = "node-a",
CorrelationId = Guid.NewGuid(),
};
/// <summary>Verifies that buffered events flush when count threshold is reached.</summary>
[Fact]
public void Buffered_events_flush_on_count_threshold()
{
var dbFactory = NewInMemoryDbFactory();
var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
// Sending exactly FlushBatchSize events triggers a flush.
for (var i = 0; i < AuditWriterActor.FlushBatchSize; i++)
actor.Tell(NewEvent(Guid.NewGuid()));
// Give the actor a beat to process the messages.
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.ConfigAuditLogs.Count().ShouldBe(AuditWriterActor.FlushBatchSize);
}, duration: TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that duplicate event IDs within a batch are deduplicated in the buffer.</summary>
[Fact]
public void Duplicate_eventIds_within_a_batch_dedup_in_buffer()
{
var dbFactory = NewInMemoryDbFactory();
var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
// Send 1000 messages, but only 100 unique EventIds (10x duplication).
var uniqueIds = Enumerable.Range(0, 100).Select(_ => Guid.NewGuid()).ToArray();
for (var i = 0; i < 1000; i++)
actor.Tell(NewEvent(uniqueIds[i % 100]));
// Force a flush — send PoisonPill, which triggers PostStop → FlushBuffer.
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor);
using var db = dbFactory.CreateDbContext();
db.ConfigAuditLogs.Count().ShouldBe(100, "in-buffer dedup should collapse duplicate EventIds");
}
/// <summary>Verifies that PostStop flushes the pending buffer.</summary>
[Fact]
public void PostStop_flushes_pending_buffer()
{
var dbFactory = NewInMemoryDbFactory();
var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
// 10 events — well below the threshold, so they sit in-buffer.
for (var i = 0; i < 10; i++)
actor.Tell(NewEvent(Guid.NewGuid()));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor);
using var db = dbFactory.CreateDbContext();
db.ConfigAuditLogs.Count().ShouldBe(10);
}
/// <summary>Verifies that EventId and CorrelationId are persisted to dedicated columns.</summary>
[Fact]
public void EventId_and_CorrelationId_are_persisted_to_dedicated_columns()
{
var dbFactory = NewInMemoryDbFactory();
var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
var eventId = Guid.NewGuid();
actor.Tell(NewEvent(eventId));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor);
using var db = dbFactory.CreateDbContext();
var row = db.ConfigAuditLogs.Single();
row.EventId.ShouldBe(eventId);
row.CorrelationId.ShouldNotBeNull();
row.DetailsJson.ShouldBe("{\"field\":\"value\"}");
row.EventType.ShouldBe("Config:Edit");
row.NodeId.ShouldBe("node-a");
}
/// <summary>Verifies that a null SourceNode/CorrelationId on the canonical event persists as null
/// (the canonical fields are now nullable; the actor must not assume they are set).</summary>
[Fact]
public void Null_sourceNode_and_correlationId_persist_as_null()
{
var dbFactory = NewInMemoryDbFactory();
var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
actor.Tell(new AuditEvent
{
EventId = Guid.NewGuid(),
Category = "Config",
Action = "Published",
Actor = "joe",
OccurredAtUtc = DateTimeOffset.UtcNow,
Outcome = AuditOutcome.Success,
SourceNode = null,
CorrelationId = null,
});
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor);
using var db = dbFactory.CreateDbContext();
var row = db.ConfigAuditLogs.Single();
row.NodeId.ShouldBeNull();
row.CorrelationId.ShouldBeNull();
}
/// <summary>Verifies the IAuditWriter.WriteAsync seam is best-effort: it completes
/// synchronously, never throws, and routes the event onto the actor's own mailbox
/// (<c>Self.Tell</c>) — i.e. the same buffer + dedup + flush pipeline asserted by the Tell
/// tests above. Reaches the concrete instance via a TestActorRef.</summary>
[Fact]
public async Task WriteAsync_is_best_effort_and_routes_onto_the_actor_mailbox()
{
var dbFactory = NewInMemoryDbFactory();
var testRef = ActorOfAsTestActorRef<AuditWriterActor>(AuditWriterActor.Props(dbFactory));
IAuditWriter writer = testRef.UnderlyingActor;
var task = writer.WriteAsync(NewEvent(Guid.NewGuid(), action: "Published"));
task.IsCompletedSuccessfully.ShouldBeTrue("WriteAsync must be best-effort and complete synchronously");
await Should.NotThrowAsync(async () => await task);
}
/// <summary>Verifies that an AuditEvent delivered to the actor's mailbox — which is exactly
/// what the WriteAsync seam does via Self.Tell — is buffered and persisted with the canonical
/// fields intact.</summary>
[Fact]
public void Mailbox_delivery_persists_the_canonical_fields()
{
var dbFactory = NewInMemoryDbFactory();
var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
var eventId = Guid.NewGuid();
actor.Tell(NewEvent(eventId, action: "Published"));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor);
using var db = dbFactory.CreateDbContext();
var row = db.ConfigAuditLogs.Single();
row.EventId.ShouldBe(eventId);
row.EventType.ShouldBe("Config:Published");
row.NodeId.ShouldBe("node-a");
}
/// <summary>Verifies the Outcome derivation table: config verbs → Success, the two
/// authorization-rejection events → Denied.</summary>
[Theory]
[InlineData("DraftCreated", AuditOutcome.Success)]
[InlineData("DraftEdited", AuditOutcome.Success)]
[InlineData("Published", AuditOutcome.Success)]
[InlineData("RolledBack", AuditOutcome.Success)]
[InlineData("NodeApplied", AuditOutcome.Success)]
[InlineData("ClusterCreated", AuditOutcome.Success)]
[InlineData("NodeAdded", AuditOutcome.Success)]
[InlineData("CredentialAdded", AuditOutcome.Success)]
[InlineData("CredentialDisabled", AuditOutcome.Success)]
[InlineData("ExternalIdReleased", AuditOutcome.Success)]
[InlineData("OpcUaAccessDenied", AuditOutcome.Denied)]
[InlineData("CrossClusterNamespaceAttempt", AuditOutcome.Denied)]
public void Outcome_is_derived_from_the_action_vocabulary(string action, AuditOutcome expected) =>
AuditOutcomeMapper.FromAction(action).ShouldBe(expected);
}