feat(audit): stamp SourceNode at CentralAuditWriter + persist via AuditLogRepository

CentralAuditWriter injects INodeIdentityProvider and stamps the event before
handing to the repository. AuditLogRepository.InsertIfNotExistsAsync now
includes SourceNode in the INSERT column list. Caller-provided value wins
(supports any future direct-write callsite that already has its own node id).
This commit is contained in:
Joseph Doherty
2026-05-23 17:11:23 -04:00
parent 479870e40c
commit 974a36826a
5 changed files with 143 additions and 6 deletions

View File

@@ -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<ArgumentNullException>(
() => new CentralAuditWriter(services, null!));
}
// ----- SourceNode stamping (Task 12) ----- //
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
INodeIdentityProvider? nodeIdentity)
{
var repo = Substitute.For<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(
provider,
NullLogger<CentralAuditWriter>.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<AuditEvent>(e => e.SourceNode == "central-a"),
Arg.Any<CancellationToken>());
}
[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<AuditEvent>(e => e.SourceNode == "central-b"),
Arg.Any<CancellationToken>());
}
[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<AuditEvent>(e => e.SourceNode == null),
Arg.Any<CancellationToken>());
}
}

View File

@@ -50,6 +50,56 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
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<AuditEvent>()
.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<AuditEvent>()
.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<MsSqlMigrationFixture>
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<MsSqlMigrationFixture>
Kind = kind,
Status = status,
SourceSiteId = siteId,
SourceNode = sourceNode,
ErrorMessage = errorMessage,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,