b7f5e887ee
Persist the canonical AuditOutcome and make structured audit rows visible. - ConfigAuditLog gains a nullable Outcome column, stored as the AuditOutcome enum member name (nvarchar(16), mirroring how AdminRole is persisted). The AuditWriterActor flush now writes Outcome = evt.Outcome.ToString(). Nullable so legacy rows and the bespoke stored-procedure path (no derived outcome) write NULL. - Migration 20260602135350_AddConfigAuditLogOutcome: additive nullable column, no backfill. Up adds the column, Down drops it. Chains after 20260602112419_CanonicalizeAdminRoles; `dotnet ef migrations has-pending-model-changes` is clean. - ClusterAudit visibility fix: the page filtered solely on ClusterId, but the structured AuditWriterActor path stamps NodeId (ClusterId null), so those rows were invisible. Extracted ClusterAuditQuery.ForClusterAsync (shared by the page and tests) which ORs in rows whose NodeId belongs to a node in the cluster — membership resolved from ClusterNode (NodeId -> ClusterId). SP-path ClusterId-stamped rows still match. Tests: ControlPlane 45/45 (adds Outcome persistence + Denied-outcome asserts); new Configuration ClusterAuditQueryTests 3/3 (both-paths visible, other-cluster excluded, page-size cap); AdminUI 121/121. Configuration Unit suite is green on a clean run (a pre-existing timing flake in ResilientConfigReaderTests, untouched here, occasionally fails under parallel load and passes in isolation).
216 lines
8.4 KiB
C#
216 lines
8.4 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");
|
|
// The derived canonical Outcome is persisted as its enum member name (Task 2.2 column).
|
|
row.Outcome.ShouldBe(nameof(AuditOutcome.Success));
|
|
}
|
|
|
|
/// <summary>Verifies that a Denied-outcome event persists "Denied" to the Outcome column.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <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);
|
|
}
|