diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 17493d6e..d9e9e2c3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -969,15 +969,25 @@ public class ManagementActor : ReceiveActor /// /// Best-effort emission of ONE secured-write AuditLog row via the central direct-write - /// path () — mirrors the - /// Notification Outbox / Inbound API central-origin pattern. The row is built through - /// the canonical so Action/Category/Outcome - /// map identically to every other emit site. + /// path () — mirrors the Notification + /// Outbox dispatch / Inbound API central-origin pattern. The row is built through the + /// canonical so Action/Category/Outcome map + /// identically to every other emit site. /// /// + /// #206: routes through (rather than calling + /// directly) so the writing + /// central node's SourceNode (central-a/central-b) is stamped via + /// the writer's — satisfying the design's + /// node-of-origin invariant. The rows are otherwise unchanged (same channel/kind, + /// same CorrelationId = the row id, same payload). + /// /// Standing audit invariant: an audit-write failure NEVER aborts the secured-write - /// action. Every exception (repository resolution OR the insert) is caught, logged at - /// warning, and swallowed — the caller's own success/failure path is authoritative. + /// action. is best-effort and swallows its own + /// internal failures; the surrounding try/catch is defensive belt-and-braces (it also + /// guards writer resolution) — either way the caller's own success/failure path is + /// authoritative. + /// /// private static async Task EmitSecuredWriteAuditAsync( IServiceProvider sp, @@ -1006,9 +1016,11 @@ public class ManagementActor : ReceiveActor verifierUser = row.VerifierUser })); - using var scope = sp.CreateScope(); - var auditRepo = scope.ServiceProvider.GetRequiredService(); - await auditRepo.InsertIfNotExistsAsync(evt); + // #206: the central direct-write writer is a singleton that opens its own + // per-call scope for the (scoped) IAuditLogRepository and stamps SourceNode + // from the local INodeIdentityProvider — no scope needed here. + var auditWriter = sp.GetRequiredService(); + await auditWriter.WriteAsync(evt); } catch (Exception ex) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs index 7ee7deb5..08da5859 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs @@ -4,9 +4,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; +using ZB.MOM.WW.ScadaBridge.AuditLog.Central; using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types; @@ -24,6 +26,14 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// public class SecuredWriteHandlerTests : TestKit, IDisposable { + /// + /// The central node the test writer stamps onto every emitted row. #206 routes + /// SecuredWrite audit through , whose + /// stamps SourceNode; the handler tests + /// assert each lifecycle row carries this value (proving the stamping actually runs). + /// + private const string ExpectedSourceNode = "central-a"; + private readonly ISiteRepository _siteRepo; private readonly ISecuredWriteRepository _securedWriteRepo; private readonly IAuditLogRepository _auditRepo; @@ -42,11 +52,28 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _services.AddScoped(_ => _securedWriteRepo); _services.AddScoped(_ => _auditRepo); _services.AddSingleton(_comms); + + // #206: route SecuredWrite audit through the REAL CentralAuditWriter (the same + // central-direct-write path Notification Outbox dispatch + Inbound API use) so + // these tests exercise SourceNode stamping end-to-end — actor → writer → repo. + // The writer stamps SourceNode from the INodeIdentityProvider (central-a/central-b) + // and persists via the captured IAuditLogRepository, so the captured rows now + // carry a non-null SourceNode. Mirrors AuditLog.Tests CentralAuditWriterTests' + // wiring (real writer + substituted repo + node identity). + var nodeIdentity = Substitute.For(); + nodeIdentity.NodeName.Returns(ExpectedSourceNode); + _services.AddSingleton(nodeIdentity); + _services.AddSingleton(sp => new CentralAuditWriter( + sp, + NullLogger.Instance, + nodeIdentity: sp.GetRequiredService())); } /// - /// Captures every handed to the substituted - /// . Audit emission is + /// Captures every that lands at the substituted + /// — i.e. the row AFTER the + /// real has stamped SourceNode (#206), so the + /// captured events carry the production node-of-origin value. Audit emission is /// best-effort and asynchronous off the actor thread, so a short await-condition /// poll lets the captured list settle before assertions. /// @@ -615,6 +642,9 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable Assert.Equal(CorrelationFor(55L), evt.CorrelationId); Assert.Equal("SecuredWrite", evt.Category); Assert.Equal("SITE1/Mx1/Tag.Setpoint", evt.Target); + // #206: the Submit row is stamped with the writing central node's identifier + // (it routed through the real CentralAuditWriter), not the legacy null SourceNode. + Assert.Equal(ExpectedSourceNode, evt.SourceNode); // Exactly one row — no stray duplicates. Assert.Single(captured); } @@ -636,6 +666,8 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable var evt = SingleOfKind(captured, AuditKind.SecuredWriteReject); Assert.Equal("bob", evt.Actor); Assert.Equal(CorrelationFor(7L), evt.CorrelationId); + // #206: Reject row carries the writing central node's identifier. + Assert.Equal(ExpectedSourceNode, evt.SourceNode); Assert.Single(captured); } @@ -657,10 +689,13 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable var approve = SingleOfKind(captured, AuditKind.SecuredWriteApprove); Assert.Equal("bob", approve.Actor); Assert.Equal(CorrelationFor(7L), approve.CorrelationId); + // #206: Approve + Execute rows both carry the writing central node's identifier. + Assert.Equal(ExpectedSourceNode, approve.SourceNode); var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute); Assert.Equal("bob", execute.Actor); Assert.Equal(CorrelationFor(7L), execute.CorrelationId); + Assert.Equal(ExpectedSourceNode, execute.SourceNode); // Delivered outcome → canonical Success. Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Success, execute.Outcome); Assert.Equal(2, captured.Count); @@ -677,6 +712,9 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable _securedWriteRepo.AddAsync(Arg.Any(), Arg.Any()) .Returns(55L); // Best-effort: a thrown audit insert must NOT abort the secured-write action. + // #206: the throw now happens behind the real CentralAuditWriter (which swallows + // it), so this still exercises the standing "audit failure never aborts the + // action" invariant — now through the central-direct-write path. _auditRepo.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromException(new InvalidOperationException("audit db down"))); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests.csproj index 282e9052..9b864eb9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests.csproj @@ -25,5 +25,8 @@ + +