diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index 0dc8e6a..3e3ed44 100644 --- a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -42,6 +42,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable private readonly SqliteConnection _connection; private readonly SqliteAuditWriterOptions _options; private readonly ILogger _logger; + private readonly INodeIdentityProvider _nodeIdentity; private readonly object _writeLock = new(); private readonly Channel _writeQueue; private readonly Task _writerLoop; @@ -50,13 +51,16 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable public SqliteAuditWriter( IOptions options, ILogger logger, + INodeIdentityProvider nodeIdentity, string? connectionStringOverride = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(nodeIdentity); _options = options.Value; _logger = logger; + _nodeIdentity = nodeIdentity; var connectionString = connectionStringOverride ?? $"Data Source={_options.DatabasePath};Cache=Shared"; @@ -325,7 +329,15 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable pKind.Value = e.Kind.ToString(); pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value; pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value; - pSourceNode.Value = (object?)e.SourceNode ?? DBNull.Value; + // SourceNode-stamping: caller-provided value wins (preserves + // rows reconciled in from other nodes via the same writer); + // otherwise stamp from the local INodeIdentityProvider. The + // event record itself is NOT mutated — stamping is at write + // time only. If the provider also returns null (unconfigured + // node), the row's SourceNode stays NULL — operators see + // "needs config" via the schema, not a magic fallback string. + var sourceNode = e.SourceNode ?? _nodeIdentity.NodeName; + pSourceNode.Value = (object?)sourceNode ?? DBNull.Value; pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value; pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value; pActor.Value = (object?)e.Actor ?? DBNull.Value; diff --git a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs index 61d0031..b5c9012 100644 --- a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs @@ -7,6 +7,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Configuration; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.HealthMonitoring; @@ -31,6 +32,10 @@ public class AddAuditLogTests var services = new ServiceCollection(); services.AddSingleton(); services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + // INodeIdentityProvider is registered by the Host's + // SiteServiceRegistration in production; AddAuditLog assumes its + // presence so SqliteAuditWriter and CentralAuditWriter can resolve. + services.AddSingleton(new FakeNodeIdentityProvider()); services.AddAuditLog(config); return services.BuildServiceProvider(); } diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs index a03bf9e..4269da3 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs @@ -9,6 +9,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.AuditLog.Tests.Integration.Infrastructure; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -114,6 +115,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:auditlog-e1-{Guid.NewGuid():N}?mode=memory&cache=shared"); diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs index 6ca8b77..e990385 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs @@ -9,6 +9,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.AuditLog.Tests.Integration.Infrastructure; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Audit; @@ -111,6 +112,7 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:auditlog-execid-{Guid.NewGuid():N}?mode=memory&cache=shared"); diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs b/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs index 538b269..c551758 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs @@ -10,6 +10,7 @@ using ScadaLink.Commons.Interfaces; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Integration; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Tests.Migrations; @@ -78,6 +79,7 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable ChannelCapacity = 1024, }), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:cachedcall-g-{Guid.NewGuid():N}?mode=memory&cache=shared"); diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/OutageReconciliationTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/OutageReconciliationTests.cs index 57295be..69000bf 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/OutageReconciliationTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/OutageReconciliationTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -148,6 +149,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:outage-{Guid.NewGuid():N}?mode=memory&cache=shared"); diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs index 1207c88..b1d08d8 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs @@ -16,6 +16,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.AuditLog.Tests.Integration.Infrastructure; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -166,6 +167,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:auditlog-parentexec-{Guid.NewGuid():N}?mode=memory&cache=shared"); var ring = new RingBufferFallback(); diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs index 99d5a7d..d89e034 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs @@ -7,6 +7,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.AuditLog.Tests.Integration.Infrastructure; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -91,12 +92,13 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture - // The 3rd constructor argument is connectionStringOverride. A unique + // The 4th constructor argument is connectionStringOverride. A unique // shared-cache in-memory URI keeps the schema scoped to this writer // instance and torn down when the writer is disposed. new SqliteAuditWriter( InMemorySqliteOptions(), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:auditlog-h-{Guid.NewGuid():N}?mode=memory&cache=shared"); private static IOptions FastTelemetryOptions() => diff --git a/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs b/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs index ca3aaab..d0c29f0 100644 --- a/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs @@ -11,6 +11,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Configuration; using ScadaLink.AuditLog.Payload; using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -89,6 +90,7 @@ public class FilterIntegrationTests var sqliteWriter = new SqliteAuditWriter( Microsoft.Extensions.Options.Options.Create(new SqliteAuditWriterOptions { DatabasePath = dataSource }), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); await using var _disposeSqlite = sqliteWriter; diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs index 95f9570..1542b9b 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Enums; @@ -39,7 +40,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable var options = new SqliteAuditWriterOptions { DatabasePath = _dbPath }; return new SqliteAuditWriter( Options.Create(options), - NullLogger.Instance); + NullLogger.Instance, + new FakeNodeIdentityProvider()); } private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => new() diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs index 26263b9..b8a4872 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -2,6 +2,7 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Enums; @@ -31,6 +32,7 @@ public class SqliteAuditWriterSchemaTests var writer = new SqliteAuditWriter( Options.Create(options), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); return (writer, dataSource); } @@ -200,6 +202,7 @@ public class SqliteAuditWriterSchemaTests return new SqliteAuditWriter( Options.Create(options), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs index 58dc32c..38324f8 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs @@ -2,7 +2,9 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.AuditLog.Tests.Site; @@ -19,7 +21,8 @@ public class SqliteAuditWriterWriteTests { private static (SqliteAuditWriter writer, string dataSource) CreateWriter( string testName, - int? channelCapacity = null) + int? channelCapacity = null, + INodeIdentityProvider? nodeIdentity = null) { var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared"; var opts = new SqliteAuditWriterOptions { DatabasePath = dataSource }; @@ -28,9 +31,15 @@ public class SqliteAuditWriterWriteTests opts.ChannelCapacity = cap; } + // Default identity provider returns null — existing tests pre-date + // SourceNode stamping and have no expectation about it. New stamping + // tests pass a real provider via the parameter. + var identity = nodeIdentity ?? new FakeNodeIdentityProvider(); + var writer = new SqliteAuditWriter( Options.Create(opts), NullLogger.Instance, + identity, connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); return (writer, dataSource); } @@ -386,4 +395,57 @@ public class SqliteAuditWriterWriteTests var row = Assert.Single(rows); Assert.Null(row.ExecutionId); } + + // ----- SourceNode stamping (Tasks 11/12) ----- // + + [Fact] + public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone() + { + var (writer, _) = CreateWriter( + nameof(WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone), + nodeIdentity: new FakeNodeIdentityProvider("node-a")); + await using var _w = writer; + + var evt = NewEvent(); + Assert.Null(evt.SourceNode); // sanity check — fresh event has no SourceNode + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Equal("node-a", row.SourceNode); + } + + [Fact] + public async Task WriteAsync_PreservesCallerProvidedSourceNode() + { + var (writer, _) = CreateWriter( + nameof(WriteAsync_PreservesCallerProvidedSourceNode), + nodeIdentity: new FakeNodeIdentityProvider("node-a")); + await using var _w = writer; + + // Reconciled rows from another node arrive with their origin's + // SourceNode already populated; the writer must preserve it. + var evt = NewEvent() with { SourceNode = "node-z" }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Equal("node-z", row.SourceNode); + } + + [Fact] + public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull() + { + var (writer, _) = CreateWriter( + nameof(WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull), + nodeIdentity: new FakeNodeIdentityProvider(nodeName: null)); + await using var _w = writer; + + var evt = NewEvent(); + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Null(row.SourceNode); + } } diff --git a/tests/ScadaLink.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs b/tests/ScadaLink.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs new file mode 100644 index 0000000..969802b --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs @@ -0,0 +1,20 @@ +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.AuditLog.Tests.TestSupport; + +/// +/// Test fake for . Returns the configured +/// verbatim — including null — so tests can +/// exercise both the "stamped" and "unconfigured" branches of the SourceNode +/// stamping logic in +/// and . +/// +internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider +{ + public string? NodeName { get; } + + public FakeNodeIdentityProvider(string? nodeName = null) + { + NodeName = nodeName; + } +} diff --git a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs index be01f04..7db23d2 100644 --- a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs +++ b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs @@ -146,8 +146,10 @@ public class SiteAuditPushFlowTests : TestKit // + Pending queue). A temp file so it survives across DI scopes. var dbPath = Path.Combine(Path.GetTempPath(), $"auditpush-{Guid.NewGuid():N}.db"); var writerOptions = Options.Create(new SqliteAuditWriterOptions { DatabasePath = dbPath }); + var nodeIdentity = Substitute.For(); + nodeIdentity.NodeName.Returns((string?)null); await using var writer = new SqliteAuditWriter( - writerOptions, NullLogger.Instance); + writerOptions, NullLogger.Instance, nodeIdentity); // Real SiteCommunicationActor. RegisterCentralClient is given the relay // standing in for the central ClusterClient.