feat(transport): AuditService stamps BundleImportId from correlation context

This commit is contained in:
Joseph Doherty
2026-05-24 03:55:17 -04:00
parent 233e0f996e
commit f32b59a557
2 changed files with 47 additions and 4 deletions

View File

@@ -1,12 +1,14 @@
using System.Text.Json; using System.Text.Json;
using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Interfaces.Transport;
namespace ScadaLink.ConfigurationDatabase.Services; namespace ScadaLink.ConfigurationDatabase.Services;
public class AuditService : IAuditService public class AuditService : IAuditService
{ {
private readonly ScadaLinkDbContext _context; private readonly ScadaLinkDbContext _context;
private readonly IAuditCorrelationContext _correlationContext;
/// <summary> /// <summary>
/// Serializer options for audit <c>afterState</c> payloads. Audit writes commit in the /// Serializer options for audit <c>afterState</c> payloads. Audit writes commit in the
@@ -21,9 +23,10 @@ public class AuditService : IAuditService
MaxDepth = 32 MaxDepth = 32
}; };
public AuditService(ScadaLinkDbContext context) public AuditService(ScadaLinkDbContext context, IAuditCorrelationContext correlationContext)
{ {
_context = context ?? throw new ArgumentNullException(nameof(context)); _context = context ?? throw new ArgumentNullException(nameof(context));
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
} }
public async Task LogAsync( public async Task LogAsync(
@@ -40,7 +43,11 @@ public class AuditService : IAuditService
Timestamp = DateTimeOffset.UtcNow, Timestamp = DateTimeOffset.UtcNow,
AfterStateJson = afterState != null AfterStateJson = afterState != null
? SerializeAfterState(afterState) ? SerializeAfterState(afterState)
: null : null,
// Stamp the active bundle import id (if any) so audit rows emitted during a
// bundle import are attributable to that import session. Null in the normal
// interactive code path.
BundleImportId = _correlationContext.BundleImportId
}; };
// Add to change tracker only — caller is responsible for calling SaveChangesAsync // Add to change tracker only — caller is responsible for calling SaveChangesAsync

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Services; using ScadaLink.ConfigurationDatabase.Services;
@@ -10,6 +11,7 @@ namespace ScadaLink.ConfigurationDatabase.Tests;
public class AuditServiceTests : IDisposable public class AuditServiceTests : IDisposable
{ {
private readonly ScadaLinkDbContext _context; private readonly ScadaLinkDbContext _context;
private readonly AuditCorrelationContext _correlationContext;
private readonly AuditService _auditService; private readonly AuditService _auditService;
public AuditServiceTests() public AuditServiceTests()
@@ -21,7 +23,8 @@ public class AuditServiceTests : IDisposable
_context = new ScadaLinkDbContext(options); _context = new ScadaLinkDbContext(options);
_context.Database.OpenConnection(); _context.Database.OpenConnection();
_context.Database.EnsureCreated(); _context.Database.EnsureCreated();
_auditService = new AuditService(_context); _correlationContext = new AuditCorrelationContext();
_auditService = new AuditService(_context, _correlationContext);
} }
public void Dispose() public void Dispose()
@@ -62,7 +65,7 @@ public class AuditServiceTests : IDisposable
.Options; .Options;
using var context2 = new ScadaLinkDbContext(options); using var context2 = new ScadaLinkDbContext(options);
var auditService2 = new AuditService(context2); var auditService2 = new AuditService(context2, new AuditCorrelationContext());
var template = new Template("RollbackTemplate"); var template = new Template("RollbackTemplate");
context2.Templates.Add(template); context2.Templates.Add(template);
@@ -125,6 +128,39 @@ public class AuditServiceTests : IDisposable
Assert.DoesNotContain(methods, m => m.Name.Contains("Delete", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(methods, m => m.Name.Contains("Delete", StringComparison.OrdinalIgnoreCase));
} }
[Fact]
public async Task LogAsync_StampsBundleImportId_FromCorrelationContext()
{
// Bundle importer sets the correlation context for the duration of ApplyAsync;
// every AuditLogEntry written under that scope must carry the BundleImportId so
// the imported configuration is attributable to the import session.
var bundleId = Guid.NewGuid();
_correlationContext.BundleImportId = bundleId;
await _auditService.LogAsync("admin", "Create", "Template", "1", "BundleImportedTemplate",
new { Name = "BundleImportedTemplate" });
await _context.SaveChangesAsync();
var audit = await _context.AuditLogEntries.SingleAsync();
Assert.Equal(bundleId, audit.BundleImportId);
}
[Fact]
public async Task LogAsync_LeavesBundleImportIdNull_WhenCorrelationContextHasNoValue()
{
// Default code path (interactive user edit, not a bundle import) must leave
// the column NULL so normal audit rows are distinguishable from bundle-import
// rows in queries and reports.
Assert.Null(_correlationContext.BundleImportId);
await _auditService.LogAsync("admin", "Create", "Template", "1", "InteractiveTemplate",
new { Name = "InteractiveTemplate" });
await _context.SaveChangesAsync();
var audit = await _context.AuditLogEntries.SingleAsync();
Assert.Null(audit.BundleImportId);
}
// Self-referential POCO used to reproduce a reference cycle in afterState. // Self-referential POCO used to reproduce a reference cycle in afterState.
private sealed class CyclicNode private sealed class CyclicNode
{ {