refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
+750
@@ -0,0 +1,750 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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<ScadaBridgeDbContext>(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<ScadaBridgeDbContext>();
|
||||
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
|
||||
var externalSystemIds = await ctx.ExternalSystemDefinitions.Select(e => e.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: templateIds,
|
||||
SharedScriptIds: sharedScriptIds,
|
||||
ExternalSystemIds: externalSystemIds,
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
// 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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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<ScadaBridgeDbContext>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle()
|
||||
{
|
||||
// T-001 regression. The Overwrite branch used to write only Description
|
||||
// / FolderId on the existing template; the bundle's Attributes / Alarms
|
||||
// / Scripts collections were silently dropped on the floor. This test
|
||||
// seeds a template with one shape, exports it, mutates the target to a
|
||||
// divergent shape, then asserts that Overwrite restores every child
|
||||
// collection AND emits per-field audit rows.
|
||||
//
|
||||
// Bundle shape (exported from "Pump"):
|
||||
// Attributes: [SetPoint (Float, 50.0), Pressure (Float, 100.0)]
|
||||
// Alarms: [HiAlarm (PriorityLevel=1)]
|
||||
// Scripts: [Init (Code="return 1;")]
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "from-bundle" };
|
||||
t.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Float, Value = "50.0" });
|
||||
t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "100.0" });
|
||||
t.Alarms.Add(new TemplateAlarm("HiAlarm") { PriorityLevel = 1, TriggerType = AlarmTriggerType.ValueMatch });
|
||||
t.Scripts.Add(new TemplateScript("Init", "return 1;"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
|
||||
// Mutate the target so every child collection diverges from the bundle.
|
||||
// Attributes: SetPoint value mutated, Pressure DELETED, NewAttr ADDED
|
||||
// Alarms: HiAlarm PriorityLevel mutated, ExtraAlarm ADDED
|
||||
// Scripts: Init code mutated, ExtraScript ADDED
|
||||
// Description also mutated so the scalar field still flips.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var pump = await ctx.Templates
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.SingleAsync(t => t.Name == "Pump");
|
||||
pump.Description = "target-mutated";
|
||||
var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint");
|
||||
setPoint.Value = "999.0"; // mutated
|
||||
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
|
||||
pump.Attributes.Remove(pressure);
|
||||
ctx.TemplateAttributes.Remove(pressure);
|
||||
pump.Attributes.Add(new TemplateAttribute("NewAttr")
|
||||
{
|
||||
DataType = DataType.String,
|
||||
Value = "should-be-deleted-by-overwrite",
|
||||
});
|
||||
var hiAlarm = pump.Alarms.Single(a => a.Name == "HiAlarm");
|
||||
hiAlarm.PriorityLevel = 99; // mutated
|
||||
pump.Alarms.Add(new TemplateAlarm("ExtraAlarm")
|
||||
{
|
||||
PriorityLevel = 5,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
});
|
||||
var initScript = pump.Scripts.Single(s => s.Name == "Init");
|
||||
initScript.Code = "return 999;"; // mutated
|
||||
pump.Scripts.Add(new TemplateScript("ExtraScript", "return 0;"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Capture the audit baseline so we can scope assertions to rows
|
||||
// emitted by THIS apply.
|
||||
int beforeMaxAuditId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
|
||||
}
|
||||
|
||||
// Act — apply Overwrite.
|
||||
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 — children mirror the bundle.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var pump = await ctx.Templates
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.SingleAsync(t => t.Name == "Pump");
|
||||
|
||||
Assert.Equal("from-bundle", pump.Description);
|
||||
|
||||
// Attributes — exactly { SetPoint, Pressure }, values restored.
|
||||
Assert.Equal(2, pump.Attributes.Count);
|
||||
var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint");
|
||||
Assert.Equal("50.0", setPoint.Value);
|
||||
Assert.Equal(DataType.Float, setPoint.DataType);
|
||||
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
|
||||
Assert.Equal("100.0", pressure.Value);
|
||||
Assert.DoesNotContain(pump.Attributes, a => a.Name == "NewAttr");
|
||||
|
||||
// Alarms — exactly { HiAlarm }, PriorityLevel restored.
|
||||
Assert.Single(pump.Alarms);
|
||||
var hi = pump.Alarms.Single();
|
||||
Assert.Equal("HiAlarm", hi.Name);
|
||||
Assert.Equal(1, hi.PriorityLevel);
|
||||
Assert.DoesNotContain(pump.Alarms, a => a.Name == "ExtraAlarm");
|
||||
|
||||
// Scripts — exactly { Init }, code restored.
|
||||
Assert.Single(pump.Scripts);
|
||||
var init = pump.Scripts.Single();
|
||||
Assert.Equal("Init", init.Name);
|
||||
Assert.Equal("return 1;", init.Code);
|
||||
Assert.DoesNotContain(pump.Scripts, s => s.Name == "ExtraScript");
|
||||
|
||||
// Per-field audit rows — design doc enumerates Added / Updated /
|
||||
// Deleted shapes; all of these should appear, all stamped with
|
||||
// the BundleImportId from the result.
|
||||
var newRows = await ctx.AuditLogEntries
|
||||
.Where(a => a.Id > beforeMaxAuditId)
|
||||
.ToListAsync();
|
||||
Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId));
|
||||
|
||||
// Attribute audit events.
|
||||
Assert.Contains(newRows, r => r.Action == "TemplateAttributeUpdated" && r.EntityName == "Pump.SetPoint");
|
||||
Assert.Contains(newRows, r => r.Action == "TemplateAttributeAdded" && r.EntityName == "Pump.Pressure");
|
||||
Assert.Contains(newRows, r => r.Action == "TemplateAttributeDeleted" && r.EntityName == "Pump.NewAttr");
|
||||
|
||||
// Alarm audit events.
|
||||
Assert.Contains(newRows, r => r.Action == "TemplateAlarmUpdated" && r.EntityName == "Pump.HiAlarm");
|
||||
Assert.Contains(newRows, r => r.Action == "TemplateAlarmDeleted" && r.EntityName == "Pump.ExtraAlarm");
|
||||
|
||||
// Script audit events.
|
||||
Assert.Contains(newRows, r => r.Action == "TemplateScriptUpdated" && r.EntityName == "Pump.Init");
|
||||
Assert.Contains(newRows, r => r.Action == "TemplateScriptDeleted" && r.EntityName == "Pump.ExtraScript");
|
||||
}
|
||||
Assert.Equal(1, result.Overwritten);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle()
|
||||
{
|
||||
// T-002 regression. The ExternalSystem Overwrite branch used to write
|
||||
// only EndpointUrl / AuthType / AuthConfiguration on the existing
|
||||
// definition; the bundle's Methods collection was silently dropped on
|
||||
// the floor. This test seeds an external system with one method
|
||||
// shape, exports it, mutates the target's methods to diverge, then
|
||||
// asserts that Overwrite restores every method AND emits per-field
|
||||
// audit rows.
|
||||
//
|
||||
// Bundle shape (exported from "Erp"):
|
||||
// Methods: [
|
||||
// GetUser (GET /users/{id}, param=A, return=R),
|
||||
// PostJob (POST /jobs, param=B, return=S),
|
||||
// ]
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var sys = new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey");
|
||||
ctx.ExternalSystemDefinitions.Add(sys);
|
||||
await ctx.SaveChangesAsync();
|
||||
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("GetUser", "GET", "/users/{id}")
|
||||
{
|
||||
ExternalSystemDefinitionId = sys.Id,
|
||||
ParameterDefinitions = "A",
|
||||
ReturnDefinition = "R",
|
||||
});
|
||||
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("PostJob", "POST", "/jobs")
|
||||
{
|
||||
ExternalSystemDefinitionId = sys.Id,
|
||||
ParameterDefinitions = "B",
|
||||
ReturnDefinition = "S",
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
|
||||
// Mutate the target so the methods diverge from the bundle:
|
||||
// GetUser — Path mutated (UPDATE expected on Overwrite)
|
||||
// PostJob — DELETED (ADD expected on Overwrite to restore)
|
||||
// ExtraOp — ADDED (DELETE expected on Overwrite to remove)
|
||||
// EndpointUrl / AuthType also mutated so the scalar update still fires.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
||||
sys.EndpointUrl = "https://wrong.example";
|
||||
sys.AuthType = "Basic";
|
||||
var getUser = await ctx.ExternalSystemMethods
|
||||
.SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "GetUser");
|
||||
getUser.Path = "/wrong/path";
|
||||
var postJob = await ctx.ExternalSystemMethods
|
||||
.SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "PostJob");
|
||||
ctx.ExternalSystemMethods.Remove(postJob);
|
||||
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("ExtraOp", "DELETE", "/extra")
|
||||
{
|
||||
ExternalSystemDefinitionId = sys.Id,
|
||||
ParameterDefinitions = "X",
|
||||
ReturnDefinition = "Y",
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Capture the audit baseline so we can scope assertions to rows
|
||||
// emitted by THIS apply.
|
||||
int beforeMaxAuditId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
|
||||
}
|
||||
|
||||
// Act — apply Overwrite on the external system.
|
||||
ImportResult result;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
result = await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Overwrite, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert — methods mirror the bundle exactly.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
||||
Assert.Equal("https://erp.example", sys.EndpointUrl);
|
||||
|
||||
var methods = await ctx.ExternalSystemMethods
|
||||
.Where(m => m.ExternalSystemDefinitionId == sys.Id)
|
||||
.ToListAsync();
|
||||
Assert.Equal(2, methods.Count);
|
||||
|
||||
var getUser = methods.Single(m => m.Name == "GetUser");
|
||||
Assert.Equal("GET", getUser.HttpMethod);
|
||||
Assert.Equal("/users/{id}", getUser.Path);
|
||||
Assert.Equal("A", getUser.ParameterDefinitions);
|
||||
Assert.Equal("R", getUser.ReturnDefinition);
|
||||
|
||||
var postJob = methods.Single(m => m.Name == "PostJob");
|
||||
Assert.Equal("POST", postJob.HttpMethod);
|
||||
Assert.Equal("/jobs", postJob.Path);
|
||||
Assert.Equal("B", postJob.ParameterDefinitions);
|
||||
Assert.Equal("S", postJob.ReturnDefinition);
|
||||
|
||||
Assert.DoesNotContain(methods, m => m.Name == "ExtraOp");
|
||||
|
||||
// Per-field audit rows — design doc enumerates Added / Updated /
|
||||
// Deleted shapes; all of these should appear, all stamped with
|
||||
// the BundleImportId from the result.
|
||||
var newRows = await ctx.AuditLogEntries
|
||||
.Where(a => a.Id > beforeMaxAuditId)
|
||||
.ToListAsync();
|
||||
Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId));
|
||||
|
||||
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodUpdated" && r.EntityName == "Erp.GetUser");
|
||||
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodAdded" && r.EntityName == "Erp.PostJob");
|
||||
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodDeleted" && r.EntityName == "Erp.ExtraOp");
|
||||
}
|
||||
Assert.Equal(1, result.Overwritten);
|
||||
}
|
||||
}
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="ZB.MOM.WW.ScadaBridge.Transport.Import.BundleImporter.PreviewAsync"/>.
|
||||
/// Reuses the same in-memory host pattern as the exporter tests: real
|
||||
/// repositories, real EF in-memory provider, real Transport pipeline. Each test
|
||||
/// seeds the target DB, exports a bundle, then loads + previews it via the
|
||||
/// importer.
|
||||
/// </summary>
|
||||
public sealed class BundleImporterPreviewTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
public BundleImporterPreviewTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseInMemoryDatabase(dbName));
|
||||
|
||||
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();
|
||||
|
||||
private async Task<Stream> ExportTemplatesAsync()
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: ids,
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
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);
|
||||
|
||||
return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<byte[]> StreamToBytes(Stream s)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await s.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match()
|
||||
{
|
||||
// Arrange: seed a template, export it, leave target unchanged. The
|
||||
// bundle's DTO is the literal projection of the target, so the diff
|
||||
// should classify it as Identical.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "stable" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleStream = await ExportTemplatesAsync();
|
||||
var bytes = await StreamToBytes(bundleStream);
|
||||
|
||||
// Act
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
||||
Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
|
||||
Assert.Null(pumpItem.FieldDiffJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_classifies_artifact_as_Modified_with_field_diff()
|
||||
{
|
||||
// Arrange: seed a template with Description="new", export it, then
|
||||
// overwrite the target template's Description with "old". The bundle's
|
||||
// version differs from the target, so the diff should flag the
|
||||
// Description field.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "new" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleStream = await ExportTemplatesAsync();
|
||||
var bytes = await StreamToBytes(bundleStream);
|
||||
|
||||
// Mutate the target between export and preview so the diff has
|
||||
// something to report. The bundle still carries Description="new".
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
|
||||
t.Description = "old";
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
||||
Assert.Equal(ConflictKind.Modified, pumpItem.Kind);
|
||||
Assert.NotNull(pumpItem.FieldDiffJson);
|
||||
// The diff should mention the Description field by name.
|
||||
Assert.Contains("Description", pumpItem.FieldDiffJson!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_classifies_artifact_as_New_when_absent_from_target()
|
||||
{
|
||||
// Arrange: seed a template, export it, then delete it from the target
|
||||
// database. The bundle still contains the template, so the diff should
|
||||
// classify it as New (target is now empty).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "to-be-deleted" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleStream = await ExportTemplatesAsync();
|
||||
var bytes = await StreamToBytes(bundleStream);
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = await ctx.Templates.SingleAsync();
|
||||
ctx.Templates.Remove(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
||||
Assert.Equal(ConflictKind.New, pumpItem.Kind);
|
||||
Assert.Null(pumpItem.FieldDiffJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_emits_Blocker_when_required_dependency_missing()
|
||||
{
|
||||
// Arrange: seed a template whose script body calls MissingHelper(), and
|
||||
// an unrelated HelperFn() shared script that *is* defined but isn't the
|
||||
// referenced one. We then export WITHOUT IncludeDependencies and use a
|
||||
// selection that only pulls the template — the bundle won't carry
|
||||
// MissingHelper (it doesn't exist anywhere) so the preview must flag it.
|
||||
//
|
||||
// To get MissingHelper into the bundle script body without the export
|
||||
// resolver pulling it in (it can't — it doesn't exist), we just seed
|
||||
// the template with a script that mentions it; the resolver scan only
|
||||
// matters for entity discovery, the body text is preserved verbatim.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
|
||||
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey"));
|
||||
|
||||
var t = new Template("Pump") { Description = "broken" };
|
||||
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleStream = await ExportTemplatesAsync();
|
||||
var bytes = await StreamToBytes(bundleStream);
|
||||
|
||||
// Wipe the SharedScripts table so MissingHelper has no chance of being
|
||||
// resolved in the target either. (HelperFn is intentionally seeded so
|
||||
// we can verify the blocker check is specific — it should NOT flag
|
||||
// HelperFn since it's in the target.)
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
// Keep HelperFn + ErpSystem so they're in the target's resolved set.
|
||||
// Just confirm via assertion that MissingHelper is the blocker name.
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Act
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
// Assert: there's at least one Blocker, and the MissingHelper one is in there.
|
||||
Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker);
|
||||
Assert.Contains(preview.Items, i =>
|
||||
i.Kind == ConflictKind.Blocker
|
||||
&& i.Name == "MissingHelper"
|
||||
&& i.BlockerReason is not null
|
||||
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
|
||||
// Conversely, HelperFn must NOT be a blocker — it's seeded in the target.
|
||||
Assert.DoesNotContain(preview.Items, i =>
|
||||
i.Kind == ConflictKind.Blocker && i.Name == "HelperFn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_does_not_flag_opcua_tag_paths_in_DataSourceReference_as_blockers()
|
||||
{
|
||||
// Arrange: a template with an attribute whose DataSourceReference is an
|
||||
// OPC UA node-address path -- e.g. "ns=3;s=Tank.Level". The segment
|
||||
// before the dot ("Tank") used to be parsed by the blocker heuristic as
|
||||
// a potential SharedScript reference, even though tag paths live in the
|
||||
// device's address space and are not script-callable.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "tag-path-check" };
|
||||
t.Attributes.Add(new TemplateAttribute("Level")
|
||||
{
|
||||
Value = "0",
|
||||
DataSourceReference = "ns=3;s=Tank.Level",
|
||||
});
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleStream = await ExportTemplatesAsync();
|
||||
var bytes = await StreamToBytes(bundleStream);
|
||||
|
||||
// Act
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
// Assert: "Tank" (the device-owned tag-path root segment) must not be
|
||||
// flagged as a missing SharedScript or ExternalSystem reference.
|
||||
Assert.DoesNotContain(preview.Items, i =>
|
||||
i.Kind == ConflictKind.Blocker && i.Name == "Tank");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_does_not_flag_stdlib_or_runtime_member_accesses_as_blockers()
|
||||
{
|
||||
// Arrange: a template script that uses a representative mix of stdlib
|
||||
// calls, runtime-API roots, and member-access patterns. None of these
|
||||
// are user-defined SharedScripts or ExternalSystems and the previous
|
||||
// heuristic was flagging every one of them.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "noise-check" };
|
||||
t.Scripts.Add(new TemplateScript("init", """
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var s = Convert.ToString(123);
|
||||
await Notify.Send("alerts", "msg");
|
||||
var x = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM t");
|
||||
var y = await ExternalSystem.Call("erp", "ping");
|
||||
obj.Dispose();
|
||||
"""));
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleStream = await ExportTemplatesAsync();
|
||||
var bytes = await StreamToBytes(bundleStream);
|
||||
|
||||
// Act
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
// Assert: none of the well-known names produce blocker rows.
|
||||
string[] noiseNames =
|
||||
{
|
||||
"DateTimeOffset", "UtcNow", "Convert", "ToString", "Notify", "Send",
|
||||
"Database", "ExecuteScalarAsync", "COUNT", "ExternalSystem", "Call",
|
||||
"Dispose",
|
||||
};
|
||||
foreach (var name in noiseNames)
|
||||
{
|
||||
Assert.DoesNotContain(preview.Items, i =>
|
||||
i.Kind == ConflictKind.Blocker && i.Name == name);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_multiple_templates_with_children_diffs_each_correctly()
|
||||
{
|
||||
// Transport-008 regression: PreviewAsync previously fetched each matching
|
||||
// template's children via a per-name GetTemplateWithChildrenAsync call
|
||||
// (N+1). The bulk variant returns every match in a single query — this
|
||||
// test seeds three templates with distinct child collections and asserts
|
||||
// the preview hydrates each one so the per-child diff sees the right
|
||||
// attribute / alarm / script counts (i.e. the bulk fetch did not lose
|
||||
// any child rows compared to the per-name fetch).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var pump = new Template("Pump") { Description = "p1" };
|
||||
pump.Attributes.Add(new TemplateAttribute("Flow"));
|
||||
pump.Scripts.Add(new TemplateScript("init", "return 1;"));
|
||||
var valve = new Template("Valve") { Description = "v1" };
|
||||
valve.Alarms.Add(new TemplateAlarm("HighPressure"));
|
||||
var tank = new Template("Tank") { Description = "t1" };
|
||||
tank.Attributes.Add(new TemplateAttribute("Level"));
|
||||
tank.Attributes.Add(new TemplateAttribute("Temperature"));
|
||||
ctx.Templates.AddRange(pump, valve, tank);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var bundleStream = await ExportTemplatesAsync();
|
||||
var bytes = await StreamToBytes(bundleStream);
|
||||
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||
preview = await importer.PreviewAsync(session.SessionId);
|
||||
}
|
||||
|
||||
// Each template should be diff-classified (Identical, since the bundle
|
||||
// is the literal projection of the target). Critically, the diff must
|
||||
// succeed for ALL three — a bulk-fetch bug that silently drops rows
|
||||
// would surface here as a missing item or a wrong (New) classification.
|
||||
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
||||
var valveItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Valve");
|
||||
var tankItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Tank");
|
||||
Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
|
||||
Assert.Equal(ConflictKind.Identical, valveItem.Kind);
|
||||
Assert.Equal(ConflictKind.Identical, tankItem.Kind);
|
||||
}
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the catch-path invariant in <see cref="BundleImporter.ApplyAsync"/>:
|
||||
/// even when the EF <c>RollbackAsync</c> itself throws (connection drop mid-
|
||||
/// rollback, provider bug, etc.) the <c>BundleImportFailed</c> audit row MUST
|
||||
/// still land, and the ORIGINAL exception (not the rollback failure) MUST
|
||||
/// propagate to the caller.
|
||||
/// <para>
|
||||
/// Uses SQLite rather than the in-memory provider because the in-memory
|
||||
/// provider's transaction is a no-op — its <c>RollbackAsync</c> never invokes
|
||||
/// the interceptor, so the throw-on-rollback path can't be exercised. SQLite
|
||||
/// :memory: is keyed per-connection, so the fixture pins a single open
|
||||
/// connection across the whole test.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The interceptor is wired to throw on <see cref="IDbTransactionInterceptor.TransactionRollingBack"/>
|
||||
/// and the async equivalent — this is the hook EF invokes synchronously inside
|
||||
/// <c>IDbContextTransaction.RollbackAsync</c>, so a throw there surfaces as
|
||||
/// the <c>RollbackAsync</c> call itself throwing, which is exactly the
|
||||
/// scenario the catch block must survive.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BundleImporterRollbackFailureTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly DbConnection _sharedConnection;
|
||||
private readonly ThrowingRollbackInterceptor _interceptor = new();
|
||||
|
||||
public BundleImporterRollbackFailureTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
|
||||
// Pin a single SQLite :memory: connection for the lifetime of the
|
||||
// fixture — :memory: is per-connection so the schema would otherwise
|
||||
// vanish between DbContext instances.
|
||||
_sharedConnection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:");
|
||||
_sharedConnection.Open();
|
||||
|
||||
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
||||
|
||||
// Register options once under the BASE DbContextOptions key, then
|
||||
// register the subclass as the scoped service used by repositories +
|
||||
// AuditService + BundleImporter. The subclass's ctor accepts
|
||||
// DbContextOptions<ScadaBridgeDbContext> (the base type's options) so the
|
||||
// single options registration serves both. This avoids the multi-options
|
||||
// pitfall of AddDbContext<TBase, TImpl> which keys options on TImpl.
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var builder = new DbContextOptionsBuilder<ScadaBridgeDbContext>();
|
||||
builder.UseSqlite(_sharedConnection);
|
||||
builder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
|
||||
builder.AddInterceptors(_interceptor);
|
||||
return builder.Options;
|
||||
});
|
||||
services.AddScoped<ScadaBridgeDbContext>(sp => new SqliteCompatibleScadaBridgeDbContext(
|
||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||
|
||||
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();
|
||||
|
||||
// Build schema once on the shared connection.
|
||||
using var scope = _provider.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_provider.Dispose();
|
||||
_sharedConnection.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_writes_BundleImportFailed_even_when_RollbackAsync_throws()
|
||||
{
|
||||
// Arrange: seed a template whose script body references MissingHelper()
|
||||
// so semantic validation will reject the apply (same broken-bundle shape
|
||||
// as BundleImporterApplyTests.ApplyAsync_rolls_back_all_changes_…). Then
|
||||
// arm the interceptor to throw on rollback so the catch path has to
|
||||
// survive a rollback failure.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
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();
|
||||
|
||||
_interceptor.ThrowOnRollback = true;
|
||||
|
||||
// Act: ApplyAsync must propagate the ORIGINAL exception
|
||||
// (SemanticValidationException) — NOT the InvalidOperationException
|
||||
// that the interceptor raises from inside RollbackAsync.
|
||||
SemanticValidationException? thrown = null;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
thrown = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "BrokenPump", ResolutionAction.Add, null) },
|
||||
user: "bob"));
|
||||
}
|
||||
Assert.NotNull(thrown);
|
||||
|
||||
// Assert: even with a rollback failure, the BundleImportFailed audit row
|
||||
// must have landed — that's the whole point of the fix. The row should
|
||||
// also carry the rollback failure's message in its AfterStateJson so
|
||||
// post-mortem readers can see both faults.
|
||||
_interceptor.ThrowOnRollback = false; // let post-condition reads roll back cleanly
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var failed = await ctx.AuditLogEntries
|
||||
.SingleOrDefaultAsync(a => a.Action == "BundleImportFailed");
|
||||
Assert.NotNull(failed);
|
||||
Assert.Equal("Bundle", failed!.EntityType);
|
||||
// Correlation MUST be null on the failure row — the rolled-back
|
||||
// BundleImportId is intentionally disowned (same contract as the
|
||||
// happy-path rollback test in BundleImporterApplyTests).
|
||||
Assert.Null(failed.BundleImportId);
|
||||
Assert.NotNull(failed.AfterStateJson);
|
||||
// The rollback exception message must be surfaced in the failure
|
||||
// row so operators can see both the cause and the rollback fault.
|
||||
Assert.Contains(
|
||||
ThrowingRollbackInterceptor.RollbackErrorMarker,
|
||||
failed.AfterStateJson!,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers (copies of the patterns from BundleImporterApplyTests) ----
|
||||
|
||||
private async Task<Guid> ExportAndLoadAsync()
|
||||
{
|
||||
Stream bundleStream;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: templateIds,
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
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<ScadaBridgeDbContext>();
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
||||
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF transaction interceptor that throws on rollback when armed. Used by
|
||||
/// <see cref="ApplyAsync_writes_BundleImportFailed_even_when_RollbackAsync_throws"/>
|
||||
/// to simulate the connection-dropped-during-rollback scenario. EF calls
|
||||
/// the async hook from inside <c>IDbContextTransaction.RollbackAsync</c>,
|
||||
/// so a throw here surfaces as <c>RollbackAsync</c> itself throwing —
|
||||
/// exactly the contract the catch block must survive.
|
||||
/// </summary>
|
||||
private sealed class ThrowingRollbackInterceptor : DbTransactionInterceptor
|
||||
{
|
||||
public const string RollbackErrorMarker = "simulated rollback failure";
|
||||
|
||||
public bool ThrowOnRollback { get; set; }
|
||||
|
||||
public override ValueTask<InterceptionResult> TransactionRollingBackAsync(
|
||||
DbTransaction transaction,
|
||||
TransactionEventData eventData,
|
||||
InterceptionResult result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ThrowOnRollback)
|
||||
{
|
||||
throw new InvalidOperationException(RollbackErrorMarker);
|
||||
}
|
||||
return base.TransactionRollingBackAsync(transaction, eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
public override InterceptionResult TransactionRollingBack(
|
||||
DbTransaction transaction,
|
||||
TransactionEventData eventData,
|
||||
InterceptionResult result)
|
||||
{
|
||||
if (ThrowOnRollback)
|
||||
{
|
||||
throw new InvalidOperationException(RollbackErrorMarker);
|
||||
}
|
||||
return base.TransactionRollingBack(transaction, eventData, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-compatible variant of <see cref="ScadaBridgeDbContext"/> used by
|
||||
/// <see cref="BundleImporterRollbackFailureTests"/>. Mirrors the adaptations in
|
||||
/// <c>SqliteTestDbContext</c> over in ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests
|
||||
/// (rowversion is nullable, DateTimeOffset stored as ISO 8601 text) but is
|
||||
/// duplicated here to avoid taking a project reference to that test project.
|
||||
/// </summary>
|
||||
internal sealed class SqliteCompatibleScadaBridgeDbContext : ScadaBridgeDbContext
|
||||
{
|
||||
public SqliteCompatibleScadaBridgeDbContext(
|
||||
DbContextOptions<ScadaBridgeDbContext> options,
|
||||
IDataProtectionProvider dataProtectionProvider)
|
||||
: base(options, dataProtectionProvider)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<DeploymentRecord>(builder =>
|
||||
{
|
||||
builder.Property(d => d.RowVersion)
|
||||
.IsRequired(false)
|
||||
.IsConcurrencyToken(false)
|
||||
.ValueGeneratedNever();
|
||||
});
|
||||
|
||||
var converter = new ValueConverter<DateTimeOffset, string>(
|
||||
v => v.UtcDateTime.ToString("o"),
|
||||
v => DateTimeOffset.Parse(v));
|
||||
var nullableConverter = new ValueConverter<DateTimeOffset?, string?>(
|
||||
v => v.HasValue ? v.Value.UtcDateTime.ToString("o") : null,
|
||||
v => v != null ? DateTimeOffset.Parse(v) : null);
|
||||
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
foreach (var property in entityType.GetProperties())
|
||||
{
|
||||
if (property.ClrType == typeof(DateTimeOffset))
|
||||
{
|
||||
property.SetValueConverter(converter);
|
||||
property.SetColumnType("TEXT");
|
||||
}
|
||||
else if (property.ClrType == typeof(DateTimeOffset?))
|
||||
{
|
||||
property.SetValueConverter(nullableConverter);
|
||||
property.SetColumnType("TEXT");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user