diff --git a/src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs b/src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs index 79bf61d..cfc0d40 100644 --- a/src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs +++ b/src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs @@ -1,12 +1,14 @@ using System.Text.Json; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Interfaces.Transport; namespace ScadaLink.ConfigurationDatabase.Services; public class AuditService : IAuditService { private readonly ScadaLinkDbContext _context; + private readonly IAuditCorrelationContext _correlationContext; /// /// Serializer options for audit afterState payloads. Audit writes commit in the @@ -21,9 +23,10 @@ public class AuditService : IAuditService MaxDepth = 32 }; - public AuditService(ScadaLinkDbContext context) + public AuditService(ScadaLinkDbContext context, IAuditCorrelationContext correlationContext) { _context = context ?? throw new ArgumentNullException(nameof(context)); + _correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext)); } public async Task LogAsync( @@ -40,7 +43,11 @@ public class AuditService : IAuditService Timestamp = DateTimeOffset.UtcNow, AfterStateJson = afterState != null ? 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 diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/AuditServiceTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/AuditServiceTests.cs index a76072c..c13e74b 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/AuditServiceTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/AuditServiceTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Services; @@ -10,6 +11,7 @@ namespace ScadaLink.ConfigurationDatabase.Tests; public class AuditServiceTests : IDisposable { private readonly ScadaLinkDbContext _context; + private readonly AuditCorrelationContext _correlationContext; private readonly AuditService _auditService; public AuditServiceTests() @@ -21,7 +23,8 @@ public class AuditServiceTests : IDisposable _context = new ScadaLinkDbContext(options); _context.Database.OpenConnection(); _context.Database.EnsureCreated(); - _auditService = new AuditService(_context); + _correlationContext = new AuditCorrelationContext(); + _auditService = new AuditService(_context, _correlationContext); } public void Dispose() @@ -62,7 +65,7 @@ public class AuditServiceTests : IDisposable .Options; using var context2 = new ScadaLinkDbContext(options); - var auditService2 = new AuditService(context2); + var auditService2 = new AuditService(context2, new AuditCorrelationContext()); var template = new Template("RollbackTemplate"); context2.Templates.Add(template); @@ -125,6 +128,39 @@ public class AuditServiceTests : IDisposable 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. private sealed class CyclicNode {