55ae230fa4
- v2-ci.yml: upload-artifact@v4 -> @v3 + continue-on-error (act_runner reports as GHES and v4 refuses GHES, failing every run); runner-capacity diagnostic step; AKKA_TEST_TIMEFACTOR=3 on the test step. - Runtime/ControlPlane harnesses: akka.test.timefactor from AKKA_TEST_TIMEFACTOR (TestTimeFactor), single-expect-default 15s; ControlPlane gains PresenceBudget. - Leaked spinner threads: the infinite-loop timeout tests (Calculation, Host.IntegrationTests RoslynVirtualTagEvaluator, 3x Core.Scripting) pinned a core for the rest of the test process (abandoned, never stopped). Now a bounded 5 s wall-clock spin; outer compile-inclusive bound 30 s. - Real test races fixed: ScriptedAlarmEngine push-during-drain (Inactive is also the start state + persist-before-update => wait for the Cleared save); DriverReconnectE2e (both nodes host the driver; Created[] is last-writer-wins and ForceReconnect is a no-op while Connecting); ControlPlaneStarterOrder (lost-race heal waited for a 60 s refresh tick). - Fixed sleeps -> poll-for-condition: ReconnectSupervisor LastError, Modbus deadband steps (wait for observed reads), Modbus dispose hygiene precondition, FOCAS probe-loop log. - Short presence budgets -> PresenceBudget in the Runtime/ControlPlane tests seen failing on Gitea; Calculation Settle 10 -> 30 s. No assertion removed, nothing skipped. - infra/README.md §10 CI; stale TreatWarningsAsErrors comment in Directory.Build.props. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
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: PresenceBudget);
|
|
}
|
|
|
|
/// <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);
|
|
}
|