5d2386cc9d
T-003: move the unlock lockout server-side. The 3-strike counter used to live in the Razor page only — a second tab / CLI caller could re-upload the same bytes and grind PBKDF2 indefinitely. The counter now lives in IBundleSessionStore, keyed by ContentHash, so retries against identical bundle bytes are throttled regardless of client. BundleLockedException surfaces the new typed error path. T-005: bind the manifest's non-derivative fields into AES-GCM AAD. A SHA-256 of the manifest (with ContentHash + Encryption normalised to sentinels) is now passed to AesGcm.Encrypt / .Decrypt, so a tampered SourceEnvironment / ExportedBy / CreatedAtUtc on a stolen bundle yields an authentication-tag mismatch instead of slipping past the Step-4 typo-resistant confirmation gate. T-006: cap zip entry count, decompressed length, and compression ratio in LoadAsync's envelope validator BEFORE any payload is decompressed, using ZipArchiveEntry.Length / .CompressedLength. New TransportOptions fields default to 4 entries / 200 MB / 50x ratio. T-007: clear decrypted plaintext on the ApplyAsync failure path and zero the buffer on success before removing the session, so a 100 MB DecryptedContent doesn't sit in memory for the 30-min TTL after a failed apply. A BundleSessionEvictionService BackgroundService now also drives EvictExpired periodically so abandoned sessions clear without needing a fresh Get() call to trigger lazy eviction. Also resolves NO-010 — the misleading "writer never throws" XML doc was the same code+comment my prior NO-004 await-the-writer fix already rewrote.
480 lines
21 KiB
C#
480 lines
21 KiB
C#
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"));
|
|
}
|
|
|
|
// T-007: a failed apply used to keep the BundleSession (and its decrypted
|
|
// secrets) in the in-memory store for the full 30-minute TTL. The session
|
|
// must now be removed immediately so the plaintext is released.
|
|
var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
|
|
Assert.Null(sessionStore.Get(sessionId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_removes_session_on_success_path_too()
|
|
{
|
|
// T-007: companion to the failed-apply test — the success path must also
|
|
// remove the session (it was already doing so before T-007, but the new
|
|
// test asserts the contract explicitly so a future refactor cannot
|
|
// accidentally leave plaintext in the store).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
ctx.Templates.Add(new Template("PumpForT007") { Description = "fresh" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
await WipeContentAsync();
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "PumpForT007", ResolutionAction.Add, null) },
|
|
user: "alice");
|
|
}
|
|
|
|
var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
|
|
Assert.Null(sessionStore.Get(sessionId));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|
|
}
|