diff --git a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs index 80bfc45..545215c 100644 --- a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs @@ -43,6 +43,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter private readonly ILogger _logger; private readonly IAuditPayloadFilter? _filter; private readonly ICentralAuditWriteFailureCounter _failureCounter; + private readonly INodeIdentityProvider? _nodeIdentity; /// /// Bundle C (M5-T6) — the central direct-write path used by the @@ -56,18 +57,27 @@ public sealed class CentralAuditWriter : ICentralAuditWriter /// throw bumps the central health surface's /// CentralAuditWriteFailures counter. Defaults to a NoOp so test /// composition roots that don't wire the counter keep their current - /// behaviour. + /// behaviour. SourceNode-stamping (Task 12) — adds the optional + /// so central-origin rows (Notification + /// Outbox dispatch, Inbound API) carry the writing central node's + /// identifier when the caller hasn't already supplied one. Optional / + /// defaulting-to-null so M4 test composition roots that don't pass a + /// provider keep working — the caller-wins discipline means an absent + /// provider simply leaves SourceNode at whatever the caller set (often + /// null, which is the legacy behaviour). /// public CentralAuditWriter( IServiceProvider services, ILogger logger, IAuditPayloadFilter? filter = null, - ICentralAuditWriteFailureCounter? failureCounter = null) + ICentralAuditWriteFailureCounter? failureCounter = null, + INodeIdentityProvider? nodeIdentity = null) { _services = services ?? throw new ArgumentNullException(nameof(services)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _filter = filter; _failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter(); + _nodeIdentity = nodeIdentity; } /// @@ -93,6 +103,18 @@ public sealed class CentralAuditWriter : ICentralAuditWriter // M4 test composition roots (no filter passed) working unchanged. var filtered = _filter?.Apply(evt) ?? evt; + // SourceNode-stamping (Task 12): caller-provided value wins + // (supports any future direct-write callsite that already has its + // own node id); otherwise stamp from the local + // INodeIdentityProvider, when one is wired. Production DI on + // central nodes always supplies the provider; legacy test + // composition roots that don't pass it leave SourceNode at + // whatever the caller set (often null), preserving back-compat. + if (filtered.SourceNode is null && _nodeIdentity?.NodeName is { } nodeName) + { + filtered = filtered with { SourceNode = nodeName }; + } + await using var scope = _services.CreateAsyncScope(); var repo = scope.ServiceProvider.GetRequiredService(); var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow }; diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index e0d9e65..7a8a775 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -183,7 +183,14 @@ public static class ServiceCollectionExtensions sp, sp.GetRequiredService>(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + // SourceNode-stamping (Task 12): wire the local node identity so + // central-origin rows (Notification Outbox dispatch, Inbound API) + // carry the writing node's identifier when the caller hasn't + // already supplied one. GetRequiredService — the production + // composition root in SiteServiceRegistration registers the + // provider as a singleton on both site and central paths. + sp.GetRequiredService())); return services; } diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 5f1dfa8..2be5862 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -65,12 +65,12 @@ public class AuditLogRepository : IAuditLogRepository $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId}) INSERT INTO dbo.AuditLog (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, + SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState) VALUES ({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId}, - {evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status}, + {evt.SourceSiteId}, {evt.SourceNode}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status}, {evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary}, {evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});", ct); diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs index 1a9ca3b..cb37e8e 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; using ScadaLink.AuditLog.Central; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -124,4 +125,59 @@ public class CentralAuditWriterTests Assert.Throws( () => new CentralAuditWriter(services, null!)); } + + // ----- SourceNode stamping (Task 12) ----- // + + private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity( + INodeIdentityProvider? nodeIdentity) + { + var repo = Substitute.For(); + var services = new ServiceCollection(); + services.AddScoped(_ => repo); + var provider = services.BuildServiceProvider(); + var writer = new CentralAuditWriter( + provider, + NullLogger.Instance, + filter: null, + failureCounter: null, + nodeIdentity: nodeIdentity); + return (writer, repo); + } + + [Fact] + public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone() + { + var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a")); + + await writer.WriteAsync(NewEvent()); + + await repo.Received(1).InsertIfNotExistsAsync( + Arg.Is(e => e.SourceNode == "central-a"), + Arg.Any()); + } + + [Fact] + public async Task WriteAsync_PreservesCallerProvidedSourceNode() + { + var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a")); + var evt = NewEvent() with { SourceNode = "central-b" }; + + await writer.WriteAsync(evt); + + await repo.Received(1).InsertIfNotExistsAsync( + Arg.Is(e => e.SourceNode == "central-b"), + Arg.Any()); + } + + [Fact] + public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull() + { + var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider(nodeName: null)); + + await writer.WriteAsync(NewEvent()); + + await repo.Received(1).InsertIfNotExistsAsync( + Arg.Is(e => e.SourceNode == null), + Arg.Any()); + } } diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index 1d7e337..669ff14 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -50,6 +50,56 @@ public class AuditLogRepositoryTests : IClassFixture Assert.Equal(evt.EventId, loaded[0].EventId); } + [SkippableFact] + public async Task InsertIfNotExistsAsync_PersistsSourceNode() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var evt = NewEvent( + siteId, + occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + sourceNode: "central-a"); + await repo.InsertIfNotExistsAsync(evt); + + await using var readContext = CreateContext(); + var loaded = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Single(loaded); + Assert.Equal("central-a", loaded[0].SourceNode); + } + + [SkippableFact] + public async Task InsertIfNotExistsAsync_PersistsNullSourceNode() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Caller passes null SourceNode (e.g. an unconfigured node) — the + // column should persist as NULL, not as the empty string. + var evt = NewEvent( + siteId, + occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + sourceNode: null); + await repo.InsertIfNotExistsAsync(evt); + + await using var readContext = CreateContext(); + var loaded = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Single(loaded); + Assert.Null(loaded[0].SourceNode); + } + [SkippableFact] public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate() { @@ -962,7 +1012,8 @@ public class AuditLogRepositoryTests : IClassFixture AuditStatus status = AuditStatus.Delivered, string? errorMessage = null, Guid? executionId = null, - Guid? parentExecutionId = null) => + Guid? parentExecutionId = null, + string? sourceNode = null) => new() { EventId = Guid.NewGuid(), @@ -971,6 +1022,7 @@ public class AuditLogRepositoryTests : IClassFixture Kind = kind, Status = status, SourceSiteId = siteId, + SourceNode = sourceNode, ErrorMessage = errorMessage, ExecutionId = executionId, ParentExecutionId = parentExecutionId,