feat(transport): BundleImporter.ApplyAsync transactional with audit correlation
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Interfaces.Transport;
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
using ScadaLink.ConfigurationDatabase.Services;
|
||||
using ScadaLink.Transport;
|
||||
using ScadaLink.Transport.Import;
|
||||
|
||||
namespace ScadaLink.Transport.IntegrationTests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="BundleImporter.ApplyAsync"/>. Reuses the
|
||||
/// in-memory host pattern from <see cref="BundleImporterPreviewTests"/> and
|
||||
/// <c>BundleExporterTests</c>: real repositories, real EF in-memory provider,
|
||||
/// real Transport pipeline.
|
||||
/// <para>
|
||||
/// In-memory EF caveat: <see cref="DbContext.Database.BeginTransactionAsync"/>
|
||||
/// is a no-op on this provider, so the rollback test depends on ApplyAsync's
|
||||
/// implementation deferring <c>SaveChangesAsync</c> to a single call just
|
||||
/// before <c>CommitAsync</c>. The implementation enforces that contract +
|
||||
/// calls <c>ChangeTracker.Clear()</c> on the catch path to defend against
|
||||
/// in-memory bleed-through; the rollback test asserts via row counts that the
|
||||
/// invariant holds.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BundleImporterApplyTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public BundleImporterApplyTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
var dbName = $"BundleImporterApplyTests_{Guid.NewGuid()}";
|
||||
// In-memory provider throws by default when BeginTransactionAsync is
|
||||
// called (InMemoryEventId.TransactionIgnoredWarning is escalated to an
|
||||
// exception). ApplyAsync legitimately opens a transaction for
|
||||
// relational providers; downgrade the warning here so the in-memory
|
||||
// run is a no-op and the rest of the apply runs through. See the
|
||||
// ApplyAsync XML comment for the rollback-safety contract that makes
|
||||
// this safe (single deferred SaveChangesAsync + ChangeTracker.Clear
|
||||
// on catch).
|
||||
services.AddDbContext<ScadaLinkDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
|
||||
_provider = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Exports the entire seeded content as a bundle, then immediately loads it
|
||||
/// via <see cref="IBundleImporter.LoadAsync"/> and returns the opened
|
||||
/// session. Used by every test that needs a session to feed
|
||||
/// <see cref="IBundleImporter.ApplyAsync"/>. Selection is "all templates +
|
||||
/// all shared scripts" because the tests want the bundle to carry whatever
|
||||
/// the test seeded.
|
||||
/// </summary>
|
||||
private async Task<Guid> ExportAndLoadAsync()
|
||||
{
|
||||
Stream bundleStream;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: templateIds,
|
||||
SharedScriptIds: sharedScriptIds,
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiKeyIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: false);
|
||||
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await bundleStream.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
|
||||
await using var loadScope = _provider.CreateAsyncScope();
|
||||
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
return session.SessionId;
|
||||
}
|
||||
|
||||
private async Task WipeContentAsync()
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
||||
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---- tests ----
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_adds_new_artifacts_in_single_transaction()
|
||||
{
|
||||
// Arrange: seed → export → wipe → apply. The wipe ensures the import
|
||||
// is exercising the Add path (the bundle's artifacts are absent from
|
||||
// the target).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
await WipeContentAsync();
|
||||
|
||||
// Act
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var resolutions = new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Pump", ResolutionAction.Add, null),
|
||||
new("SharedScript", "HelperFn", ResolutionAction.Add, null),
|
||||
};
|
||||
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
|
||||
}
|
||||
|
||||
// Assert
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Pump"));
|
||||
Assert.Equal(1, await ctx.SharedScripts.CountAsync(s => s.Name == "HelperFn"));
|
||||
}
|
||||
Assert.Equal(2, result.Added);
|
||||
Assert.Equal(0, result.Overwritten);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
Assert.NotEqual(Guid.Empty, result.BundleImportId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite()
|
||||
{
|
||||
// Arrange: seed Pump with Description=new, export, then mutate to
|
||||
// Description=old. The bundle still carries "new". Overwrite must
|
||||
// restore the description.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "new" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
|
||||
t.Description = "old";
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
|
||||
Assert.Equal("new", t.Description);
|
||||
}
|
||||
Assert.Equal(1, result.Overwritten);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_skips_artifact_when_resolution_is_Skip()
|
||||
{
|
||||
// Arrange: identical seed + bundle; Skip resolution should leave
|
||||
// target unchanged and bump Skipped count.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "stable" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
|
||||
// Act
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Skip, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
// Exactly one Pump still, with Description unchanged.
|
||||
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
|
||||
Assert.Equal("stable", t.Description);
|
||||
}
|
||||
Assert.Equal(1, result.Skipped);
|
||||
Assert.Equal(0, result.Added);
|
||||
Assert.Equal(0, result.Overwritten);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_renames_artifact_when_resolution_is_Rename()
|
||||
{
|
||||
// Arrange: seed X, export, wipe so the Rename target Y doesn't
|
||||
// collide. Apply Rename X→Y.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.Templates.Add(new Template("X") { Description = "orig" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
await WipeContentAsync();
|
||||
|
||||
// Act
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "X", ResolutionAction.Rename, "Y") },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
Assert.Equal(0, await ctx.Templates.CountAsync(t => t.Name == "X"));
|
||||
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Y"));
|
||||
}
|
||||
Assert.Equal(1, result.Renamed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails()
|
||||
{
|
||||
// Arrange: seed a template whose script body calls MissingHelper().
|
||||
// No SharedScript by that name exists in source or (after wipe) in the
|
||||
// target, so semantic validation must reject the apply.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = new Template("BrokenPump") { Description = "broken" };
|
||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
await WipeContentAsync();
|
||||
|
||||
// Act
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
// Assert — target still wiped (template not committed), AND a
|
||||
// BundleImportFailed row exists.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
Assert.Equal(0, await ctx.Templates.CountAsync());
|
||||
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row()
|
||||
{
|
||||
// The correlation guarantee — every per-entity audit row emitted during
|
||||
// ApplyAsync must carry the same BundleImportId as the returned result.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
await WipeContentAsync();
|
||||
// Snapshot the audit-row ids before the apply so the assertion only
|
||||
// looks at rows the apply itself emitted (the export wrote a
|
||||
// BundleExported row too, with no BundleImportId — that's correct, it
|
||||
// wasn't part of an import).
|
||||
int beforeMaxId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
beforeMaxId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
|
||||
}
|
||||
|
||||
// Act
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Pump", ResolutionAction.Add, null),
|
||||
new("SharedScript", "HelperFn", ResolutionAction.Add, null),
|
||||
},
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var newRows = await ctx.AuditLogEntries.Where(a => a.Id > beforeMaxId).ToListAsync();
|
||||
// We expect at least: TemplateCreated + SharedScriptCreated + BundleImported.
|
||||
Assert.True(newRows.Count >= 3,
|
||||
$"Expected at least 3 new audit rows, got {newRows.Count}.");
|
||||
Assert.All(newRows, row =>
|
||||
Assert.Equal(result.BundleImportId, row.BundleImportId));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_writes_BundleImported_summary_row_inside_transaction()
|
||||
{
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
await WipeContentAsync();
|
||||
|
||||
// Act
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Add, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert: BundleImported row exists, has the right SourceEnvironment in
|
||||
// its AfterStateJson, and carries the BundleImportId from the result.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImported");
|
||||
Assert.NotNull(row);
|
||||
Assert.Equal("Bundle", row!.EntityType);
|
||||
Assert.Equal(result.BundleImportId, row.BundleImportId);
|
||||
Assert.NotNull(row.AfterStateJson);
|
||||
Assert.Contains("dev", row.AfterStateJson!, StringComparison.Ordinal);
|
||||
// Summary block in payload.
|
||||
Assert.Contains("Summary", row.AfterStateJson!, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction()
|
||||
{
|
||||
// Paired with the rollback test — the failure row IS present even
|
||||
// though every other write was rolled back, AND it carries
|
||||
// BundleImportId == null (the rolled-back id is intentionally
|
||||
// disowned from the failure record).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var t = new Template("BrokenPump") { Description = "broken" };
|
||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
await WipeContentAsync();
|
||||
|
||||
// Act
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
// Assert
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImportFailed");
|
||||
Assert.NotNull(row);
|
||||
Assert.Equal("Bundle", row!.EntityType);
|
||||
// Correlation MUST be null on the failure row — the rolled-back
|
||||
// BundleImportId is intentionally disowned.
|
||||
Assert.Null(row.BundleImportId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user