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 @@
+
+