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(), }; /// Verifies that buffered events flush when count threshold is reached. [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)); } /// Verifies that duplicate event IDs within a batch are deduplicated in the buffer. [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"); } /// Verifies that PostStop flushes the pending buffer. [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); } /// Verifies that EventId and CorrelationId are persisted to dedicated columns. [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"); } /// 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). [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(); } /// Verifies the IAuditWriter.WriteAsync seam is best-effort: it completes /// synchronously, never throws, and routes the event onto the actor's own mailbox /// (Self.Tell) — i.e. the same buffer + dedup + flush pipeline asserted by the Tell /// tests above. Reaches the concrete instance via a TestActorRef. [Fact] public async Task WriteAsync_is_best_effort_and_routes_onto_the_actor_mailbox() { var dbFactory = NewInMemoryDbFactory(); var testRef = ActorOfAsTestActorRef(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); } /// 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. [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"); // The derived canonical Outcome is persisted as its enum member name (Task 2.2 column). row.Outcome.ShouldBe(nameof(AuditOutcome.Success)); } /// Verifies that a Denied-outcome event persists "Denied" to the Outcome column. [Fact] public void Denied_outcome_is_persisted_as_its_enum_member_name() { var dbFactory = NewInMemoryDbFactory(); var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory)); actor.Tell(NewEvent(Guid.NewGuid(), action: "OpcUaAccessDenied")); Watch(actor); actor.Tell(PoisonPill.Instance); ExpectTerminated(actor); using var db = dbFactory.CreateDbContext(); var row = db.ConfigAuditLogs.Single(); row.Outcome.ShouldBe(nameof(AuditOutcome.Denied)); row.EventType.ShouldBe("Config:OpcUaAccessDenied"); } /// Verifies the Outcome derivation table: config verbs → Success, the two /// authorization-rejection events → Denied. [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); }