feat(transport): AuditService stamps BundleImportId from correlation context
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user