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:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,311 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
/// <summary>
/// FU-B — integration coverage for the post-flush second-pass rewire in
/// <c>BundleImporter.ApplyAsync</c>: composition edges (<c>#39</c>) and
/// alarm-script FKs (remainder of <c>#37</c>). All three scenarios drive the
/// full export → load → apply pipeline so the wire-level DTO carries the
/// name-keyed references the importer is expected to resolve.
/// </summary>
public sealed class CompositionImportTests : IDisposable
{
private readonly ServiceProvider _provider;
public CompositionImportTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
var dbName = $"CompositionImportTests_{Guid.NewGuid()}";
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();
/// <summary>
/// Builds a bundle of the templates currently in the DB and returns the
/// raw bytes. Mirrors the helper in <c>RoundTripTests</c> but parameterised
/// to keep the per-test setup terse.
/// </summary>
private async Task<byte[]> ExportAllTemplatesAsync()
{
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);
var stream = await exporter.ExportAsync(selection,
user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
return ms.ToArray();
}
private async Task WipeTemplatesAsync()
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms);
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
ctx.Templates.RemoveRange(ctx.Templates);
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
await ctx.SaveChangesAsync();
}
[Fact]
public async Task Composition_edges_are_restored_on_import()
{
// Seed: Template A composes Template B via InstanceName="b1".
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("B") { Description = "leaf" });
await ctx.SaveChangesAsync();
var b = await ctx.Templates.SingleAsync(t => t.Name == "B");
var a = new Template("A") { Description = "composer" };
ctx.Templates.Add(a);
await ctx.SaveChangesAsync();
a.Compositions.Add(new TemplateComposition("b1")
{
TemplateId = a.Id,
ComposedTemplateId = b.Id,
});
await ctx.SaveChangesAsync();
}
var bundleBytes = await ExportAllTemplatesAsync();
await WipeTemplatesAsync();
// Load + preview + apply with Add for both.
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
using var ms = new MemoryStream(bundleBytes, writable: false);
var session = await importer.LoadAsync(ms, passphrase: null);
sessionId = session.SessionId;
}
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
preview = await importer.PreviewAsync(sessionId);
}
var resolutions = preview.Items
.Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null))
.ToList();
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await importer.ApplyAsync(sessionId, resolutions, user: "bob");
}
// Assert: A has exactly one TemplateComposition pointing at B.
await using (var assertScope = _provider.CreateAsyncScope())
{
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var a = await ctx.Templates
.Include(t => t.Compositions)
.SingleAsync(t => t.Name == "A");
var comp = Assert.Single(a.Compositions);
Assert.Equal("b1", comp.InstanceName);
var b = await ctx.Templates.SingleAsync(t => t.Name == "B");
Assert.Equal(b.Id, comp.ComposedTemplateId);
}
}
[Fact]
public async Task Composition_referencing_skipped_template_emits_unresolved_audit_and_skips_edge()
{
// Same seed as the happy-path test; the divergence is in the Apply
// resolutions — B is Skip-resolved so its composition reference is
// expected to surface as a BundleImportCompositionUnresolved audit row
// and the composition edge must NOT be written.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("B") { Description = "leaf" });
await ctx.SaveChangesAsync();
var b = await ctx.Templates.SingleAsync(t => t.Name == "B");
var a = new Template("A") { Description = "composer" };
ctx.Templates.Add(a);
await ctx.SaveChangesAsync();
a.Compositions.Add(new TemplateComposition("b1")
{
TemplateId = a.Id,
ComposedTemplateId = b.Id,
});
await ctx.SaveChangesAsync();
}
var bundleBytes = await ExportAllTemplatesAsync();
await WipeTemplatesAsync();
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
using var ms = new MemoryStream(bundleBytes, writable: false);
var session = await importer.LoadAsync(ms, passphrase: null);
sessionId = session.SessionId;
}
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
// Add A but Skip B. The composition's ComposedTemplateName="B"
// therefore can't resolve (B isn't being written and isn't in the
// target — we wiped) and must surface as an unresolved audit row.
var resolutions = new List<ImportResolution>
{
new("Template", "A", ResolutionAction.Add, null),
new("Template", "B", ResolutionAction.Skip, null),
};
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
}
await using (var assertScope = _provider.CreateAsyncScope())
{
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var a = await ctx.Templates
.Include(t => t.Compositions)
.SingleAsync(t => t.Name == "A");
// Composition couldn't resolve → zero edges on A.
Assert.Empty(a.Compositions);
// B was skipped → still not in target.
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "B"));
// BundleImportCompositionUnresolved audit row exists and is
// correlated by the run's BundleImportId.
var unresolved = await ctx.AuditLogEntries
.Where(e => e.Action == "BundleImportCompositionUnresolved")
.ToListAsync();
var row = Assert.Single(unresolved);
Assert.Equal(result.BundleImportId, row.BundleImportId);
Assert.Equal("TemplateComposition", row.EntityType);
// Entity name is "<owner>.<instance>" so an operator can find the
// offending row in the Configuration Audit Log Viewer quickly.
Assert.Equal("A.b1", row.EntityName);
}
}
[Fact]
public async Task OnTriggerScript_alarm_link_is_restored_on_import()
{
// Seed: a template with a "Startup" script and an alarm whose
// OnTriggerScriptId points at "Startup". The DTO carries
// OnTriggerScriptName="Startup" and the importer's second pass should
// re-resolve the FK to Startup's new id.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("Pump") { Description = "with alarm" };
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
var script = new TemplateScript("Startup", "return 1;")
{
TemplateId = t.Id,
};
t.Scripts.Add(script);
await ctx.SaveChangesAsync();
t.Alarms.Add(new TemplateAlarm("High")
{
TemplateId = t.Id,
PriorityLevel = 2,
TriggerType = AlarmTriggerType.RangeViolation,
OnTriggerScriptId = script.Id,
});
await ctx.SaveChangesAsync();
}
var bundleBytes = await ExportAllTemplatesAsync();
await WipeTemplatesAsync();
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
using var ms = new MemoryStream(bundleBytes, writable: false);
var session = await importer.LoadAsync(ms, passphrase: null);
sessionId = session.SessionId;
}
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var preview = await importer.PreviewAsync(sessionId);
var resolutions = preview.Items
.Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null))
.ToList();
await importer.ApplyAsync(sessionId, resolutions, user: "bob");
}
await using (var assertScope = _provider.CreateAsyncScope())
{
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var pump = await ctx.Templates
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.SingleAsync(t => t.Name == "Pump");
var script = Assert.Single(pump.Scripts);
Assert.Equal("Startup", script.Name);
var alarm = Assert.Single(pump.Alarms);
Assert.Equal("High", alarm.Name);
// FK rewired to the imported Startup script's NEW id (not the
// pre-export id, which is gone after the wipe).
Assert.Equal(script.Id, alarm.OnTriggerScriptId);
}
}
}
@@ -0,0 +1,231 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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;
/// <summary>
/// T26 — integration conflict-resolution tests. The unit-level Apply paths
/// in <c>BundleImporterApplyTests</c> exercise hand-built sessions; this
/// suite drives the full export→load→apply pipeline so the wire-level
/// session round-trip is part of the assertion.
/// </summary>
public sealed class ConflictResolutionTests : IDisposable
{
private readonly ServiceProvider _provider;
public ConflictResolutionTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
var dbName = $"ConflictResolutionTests_{Guid.NewGuid()}";
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();
/// <summary>
/// Exports the current set of templates+shared scripts to a freshly built
/// bundle and immediately loads it into a session. Returns the
/// <see cref="BundleSession.SessionId"/> the caller can hand to ApplyAsync.
/// </summary>
private async Task<Guid> ExportAndLoadAsync()
{
byte[] bundleBytes;
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 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);
var stream = await exporter.ExportAsync(selection,
user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
await using var loadScope = _provider.CreateAsyncScope();
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
using var input = new MemoryStream(bundleBytes, writable: false);
var session = await importer.LoadAsync(input, passphrase: null);
return session.SessionId;
}
[Fact]
public async Task Overwrite_replaces_existing_template_description()
{
// Arrange: seed Pump with Description="new", export it (the bundle
// therefore carries "new"), then mutate the target's Pump to "old".
// Apply with Overwrite must restore "new".
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 existing = await ctx.Templates.SingleAsync(t => t.Name == "Pump");
existing.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 pump = await ctx.Templates.SingleAsync(t => t.Name == "Pump");
Assert.Equal("new", pump.Description);
}
Assert.Equal(1, result.Overwritten);
}
[Fact]
public async Task Skip_leaves_existing_template_unchanged()
{
// Arrange: seed Pump with Description="keep", export, then mutate to
// "replace" so the bundle's body diverges. Skip must NOT touch the
// target row, and the summary must report Skipped=1.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "replace" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var existing = await ctx.Templates.SingleAsync(t => t.Name == "Pump");
existing.Description = "keep";
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.Skip, null),
},
user: "bob");
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var pump = await ctx.Templates.SingleAsync(t => t.Name == "Pump");
Assert.Equal("keep", pump.Description);
}
Assert.Equal(1, result.Skipped);
Assert.Equal(0, result.Added);
Assert.Equal(0, result.Overwritten);
}
[Fact]
public async Task Rename_creates_new_template_alongside_existing()
{
// Arrange: seed Pump, export, mutate description so the rename target
// is obviously the bundle's version. The original Pump must survive
// untouched and a second Pump.Imported template must materialise.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "from-bundle" });
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var existing = await ctx.Templates.SingleAsync(t => t.Name == "Pump");
existing.Description = "kept-original";
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.Rename, "Pump.Imported"),
},
user: "bob");
}
// Assert
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var original = await ctx.Templates.SingleAsync(t => t.Name == "Pump");
Assert.Equal("kept-original", original.Description);
var renamed = await ctx.Templates.SingleAsync(t => t.Name == "Pump.Imported");
Assert.Equal("from-bundle", renamed.Description);
}
Assert.Equal(1, result.Renamed);
}
}
@@ -0,0 +1,282 @@
using System.Security.Cryptography;
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.Notifications;
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.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Export;
/// <summary>
/// Integration tests for <see cref="ZB.MOM.WW.ScadaBridge.Transport.Export.BundleExporter"/>.
/// Builds a self-contained DI container (in-memory EF + the repositories +
/// AddTransport's pipeline) and asserts the exporter round-trips a real bundle
/// + writes the matching audit row. Using <see cref="ServiceCollection"/>
/// directly rather than <c>WebApplicationFactory</c> keeps the test focused on
/// the exporter — no HTTP, no Akka, no LDAP.
/// </summary>
public sealed class BundleExporterTests : IDisposable
{
private readonly ServiceProvider _provider;
public BundleExporterTests()
{
var services = new ServiceCollection();
// AddTransport's BindConfiguration call needs an IConfiguration in the
// container — an empty one is fine for these tests because the defaults
// baked into TransportOptions are exactly what we want.
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
// In-memory EF — fresh database per test instance so seeded ids are
// predictable and the audit assertions can scan the whole table. The
// db name has to be resolved ONCE (outside the lambda) so every scope
// resolves the same underlying InMemoryStore — otherwise each scope's
// DbContext gets a new GUID and the seeded rows vanish between scopes.
var dbName = $"BundleExporterTests_{Guid.NewGuid()}";
services.AddDbContext<ScadaBridgeDbContext>(opts =>
opts.UseInMemoryDatabase(dbName));
// Repositories the resolver pulls from.
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// Audit pipeline — AuditService writes via the EF context + reads the
// bundle-import correlation id from the scoped context (null here, since
// export is not part of an import session).
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
// Add the transport pipeline itself.
services.AddTransport();
_provider = services.BuildServiceProvider();
}
public void Dispose() => _provider.Dispose();
[Fact]
public async Task ExportAsync_writes_audit_event_and_returns_valid_bundle()
{
// Arrange: seed two templates (one composing the other), a shared
// script referenced from the parent template, one external system, one
// notification list. Names live in the script bodies / DataSourceReference
// so the dependency resolver's substring scan picks them up.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var helper = new SharedScript("HelperFn", "return 1;");
ctx.SharedScripts.Add(helper);
var erp = new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey");
ctx.ExternalSystemDefinitions.Add(erp);
var list = new NotificationList("OnCall");
ctx.NotificationLists.Add(list);
await ctx.SaveChangesAsync();
var baseTemplate = new Template("BaseDevice")
{
Description = "shared base",
};
// Attribute body references the helper + external system, so
// IncludeDependencies=true pulls them in automatically.
baseTemplate.Scripts.Add(new TemplateScript("init",
"var x = HelperFn(); ErpSystem.Call(\"x\");"));
ctx.Templates.Add(baseTemplate);
await ctx.SaveChangesAsync();
var composing = new Template("Pump")
{
Description = "composes BaseDevice",
};
ctx.Templates.Add(composing);
await ctx.SaveChangesAsync();
composing.Compositions.Add(new TemplateComposition("base")
{
TemplateId = composing.Id,
ComposedTemplateId = baseTemplate.Id,
});
await ctx.SaveChangesAsync();
}
// Act
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: true);
bundleStream = await exporter.ExportAsync(
selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
// Copy to byte[] so we can re-open the zip multiple times below.
byte[] bundleBytes;
using (var ms = new MemoryStream())
{
await bundleStream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
// Assert: non-empty, valid zip with the expected manifest + content.
Assert.NotEmpty(bundleBytes);
var serializer = _provider.GetRequiredService<BundleSerializer>();
BundleManifest manifest;
using (var ms = new MemoryStream(bundleBytes, writable: false))
{
manifest = serializer.ReadManifest(ms);
}
Assert.Equal("dev", manifest.SourceEnvironment);
Assert.Equal("alice", manifest.ExportedBy);
Assert.Null(manifest.Encryption);
Assert.Equal(2, manifest.Summary.Templates);
Assert.Equal(1, manifest.Summary.SharedScripts);
Assert.Equal(1, manifest.Summary.ExternalSystems);
byte[] rawContent;
using (var ms = new MemoryStream(bundleBytes, writable: false))
{
rawContent = serializer.ReadContentBytes(ms, manifest);
}
var content = serializer.UnpackContent(rawContent, manifest, passphrase: null, encryptor: null);
Assert.Equal(2, content.Templates.Count);
Assert.Contains(content.Templates, t => t.Name == "BaseDevice");
Assert.Contains(content.Templates, t => t.Name == "Pump");
Assert.Single(content.SharedScripts);
Assert.Equal("HelperFn", content.SharedScripts[0].Name);
Assert.Single(content.ExternalSystems);
Assert.Equal("ErpSystem", content.ExternalSystems[0].Name);
// Audit row landed with the expected shape — Action discriminates the
// unencrypted export path, EntityId is the SHA-256 of the zip bytes.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var entry = await ctx.AuditLogEntries.SingleAsync();
Assert.Equal("UnencryptedBundleExport", entry.Action);
Assert.Equal("alice", entry.User);
Assert.Equal("Bundle", entry.EntityType);
Assert.Equal("dev", entry.EntityName);
var expectedHash = "sha256:" + Convert.ToHexString(SHA256.HashData(bundleBytes))
.ToLowerInvariant();
Assert.Equal(expectedHash, entry.EntityId);
}
}
[Fact]
public async Task ExportAsync_with_passphrase_produces_encrypted_bundle()
{
// Arrange: minimal template only — we want to exercise the encryption
// path, not the dependency resolver.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Solo") { Description = "alone" });
await ctx.SaveChangesAsync();
}
// Act
Stream bundleStream;
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: true);
bundleStream = await exporter.ExportAsync(
selection, user: "bob", sourceEnvironment: "stg",
passphrase: "correct horse battery staple",
cancellationToken: CancellationToken.None);
}
byte[] bundleBytes;
using (var ms = new MemoryStream())
{
await bundleStream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
// Assert: manifest carries fresh encryption metadata; wrong passphrase
// throws (AES-GCM tag mismatch); audit action is "BundleExported".
var serializer = _provider.GetRequiredService<BundleSerializer>();
var encryptor = _provider.GetRequiredService<BundleSecretEncryptor>();
BundleManifest manifest;
byte[] rawContent;
using (var ms = new MemoryStream(bundleBytes, writable: false))
{
manifest = serializer.ReadManifest(ms);
}
using (var ms = new MemoryStream(bundleBytes, writable: false))
{
rawContent = serializer.ReadContentBytes(ms, manifest);
}
Assert.NotNull(manifest.Encryption);
Assert.Equal("AES-256-GCM", manifest.Encryption!.Algorithm);
Assert.False(string.IsNullOrEmpty(manifest.Encryption.SaltB64));
Assert.False(string.IsNullOrEmpty(manifest.Encryption.IvB64));
// Wrong passphrase fails AES-GCM tag verification — the runtime throws
// the more specific AuthenticationTagMismatchException (a CryptographicException
// subclass on .NET 10), so ThrowsAny is correct here.
Assert.ThrowsAny<CryptographicException>(() =>
serializer.UnpackContent(rawContent, manifest, "wrong", encryptor));
// Right passphrase unpacks back to the seeded template.
var content = serializer.UnpackContent(
rawContent, manifest, "correct horse battery staple", encryptor);
Assert.Single(content.Templates);
Assert.Equal("Solo", content.Templates[0].Name);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var entry = await ctx.AuditLogEntries.SingleAsync();
Assert.Equal("BundleExported", entry.Action);
Assert.Equal("bob", entry.User);
Assert.Equal("stg", entry.EntityName);
}
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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");
}
}
}
}
}
@@ -0,0 +1,289 @@
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.Notifications;
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.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
/// <summary>
/// T25 — full export→wipe→import round-trip through the real DI pipeline.
/// Confirms the bundle's content survives a complete cycle and that the
/// audit story (BundleExported on the way out, BundleImported on the way
/// in, plus per-entity stamping with BundleImportId) lines up.
/// <para>
/// Uses the same in-memory host pattern as <c>BundleExporterTests</c> and
/// <c>BundleImporterApplyTests</c> — real repositories, in-memory EF
/// provider, real Transport pipeline. The InMemory transaction warning is
/// downgraded for the same reason it is in <c>BundleImporterApplyTests</c>:
/// <c>ApplyAsync</c> opens a transaction that's a no-op here, and the
/// rollback-safety contract (single deferred SaveChangesAsync +
/// ChangeTracker.Clear on catch) keeps the test honest.
/// </para>
/// </summary>
public sealed class RoundTripTests : IDisposable
{
private readonly ServiceProvider _provider;
public RoundTripTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
// Same db across all scopes — see BundleExporterTests for the rationale.
var dbName = $"RoundTripTests_{Guid.NewGuid()}";
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();
[Fact]
public async Task Export_then_wipe_then_import_restores_state()
{
// ---- 1. Seed: 3 templates (one composing another + a standalone), 1
// SharedScript referenced by template script body, 1
// ExternalSystem referenced by template script body, 1
// NotificationList. ----
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"));
ctx.NotificationLists.Add(new NotificationList("OnCall"));
await ctx.SaveChangesAsync();
var baseTemplate = new Template("BaseDevice") { Description = "shared base" };
// Script body references HelperFn + ErpSystem so the resolver pulls
// them in via the substring scan. We deliberately keep the body
// free of method-call tokens (e.g. `.Call(`) so the importer's
// identifier-scan doesn't flag well-known SDK methods as missing
// SharedScript blockers.
baseTemplate.Scripts.Add(new TemplateScript("init",
"var x = HelperFn(); var sys = ErpSystem;"));
ctx.Templates.Add(baseTemplate);
await ctx.SaveChangesAsync();
var composing = new Template("Pump") { Description = "composes BaseDevice" };
ctx.Templates.Add(composing);
await ctx.SaveChangesAsync();
composing.Compositions.Add(new TemplateComposition("base")
{
TemplateId = composing.Id,
ComposedTemplateId = baseTemplate.Id,
});
ctx.Templates.Add(new Template("Standalone") { Description = "no deps" });
await ctx.SaveChangesAsync();
}
// ---- 2. Export everything as a single encrypted bundle. ----
byte[] bundleBytes;
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(x => x.Id).ToListAsync();
var notificationListIds = await ctx.NotificationLists.Select(n => n.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: templateIds,
SharedScriptIds: sharedScriptIds,
ExternalSystemIds: externalSystemIds,
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: notificationListIds,
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true);
var stream = await exporter.ExportAsync(
selection, user: "alice", sourceEnvironment: "dev",
passphrase: "secret", cancellationToken: CancellationToken.None);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
Assert.NotEmpty(bundleBytes);
// Read manifest from the bundle so we can pin the ContentHash that
// the BundleImported audit row should echo back inside its
// AfterStateJson — that's the round-trip identity check.
string manifestContentHash;
await using (var scope = _provider.CreateAsyncScope())
{
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
using var ms = new MemoryStream(bundleBytes, writable: false);
var manifest = serializer.ReadManifest(ms);
manifestContentHash = manifest.ContentHash;
}
Assert.False(string.IsNullOrEmpty(manifestContentHash));
// ---- 3. Wipe — delete every seeded entity AND the audit rows the
// export wrote so the post-import assertion can scan the
// whole table unambiguously. ----
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
ctx.Templates.RemoveRange(ctx.Templates);
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions);
ctx.NotificationRecipients.RemoveRange(ctx.NotificationRecipients);
ctx.NotificationLists.RemoveRange(ctx.NotificationLists);
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
await ctx.SaveChangesAsync();
}
// ---- 4. Load the bundle → session. ----
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
using var ms = new MemoryStream(bundleBytes, writable: false);
var session = await importer.LoadAsync(ms, passphrase: "secret");
sessionId = session.SessionId;
}
// ---- 5. Preview — every item must be ConflictKind.New now that the
// target is empty. ----
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
preview = await importer.PreviewAsync(sessionId);
}
Assert.NotEmpty(preview.Items);
Assert.All(preview.Items, item =>
Assert.Equal(ConflictKind.New, item.Kind));
// ---- 6. Build resolutions: every item gets Add. ----
var resolutions = preview.Items
.Select(it => new ImportResolution(it.EntityType, it.Name, ResolutionAction.Add, null))
.ToList();
// ---- 7. Apply. ----
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
}
// ---- 8. Assertions ----
await using (var assertScope = _provider.CreateAsyncScope())
{
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// 8a. Original entities are all back. Re-query by name and spot-check
// the surviving key fields.
var baseDevice = await ctx.Templates
.Include(t => t.Scripts)
.SingleAsync(t => t.Name == "BaseDevice");
Assert.Equal("shared base", baseDevice.Description);
Assert.Single(baseDevice.Scripts);
Assert.Equal("init", baseDevice.Scripts.First().Name);
var pump = await ctx.Templates
.Include(t => t.Compositions)
.SingleAsync(t => t.Name == "Pump");
Assert.Equal("composes BaseDevice", pump.Description);
// FU-B / #39 — composition graph IS restored on import. The bundle
// carried Pump composing BaseDevice via InstanceName="base"; the
// importer's second pass (ResolveCompositionEdgesAsync) re-resolved
// ComposedTemplateName to BaseDevice's new id after the template
// flush and re-added the row.
var pumpComp = Assert.Single(pump.Compositions);
Assert.Equal("base", pumpComp.InstanceName);
Assert.Equal(baseDevice.Id, pumpComp.ComposedTemplateId);
var standalone = await ctx.Templates.SingleAsync(t => t.Name == "Standalone");
Assert.Equal("no deps", standalone.Description);
var helper = await ctx.SharedScripts.SingleAsync(s => s.Name == "HelperFn");
Assert.Equal("return 1;", helper.Code);
var erp = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "ErpSystem");
Assert.Equal("https://erp.example", erp.EndpointUrl);
var onCall = await ctx.NotificationLists.SingleAsync(n => n.Name == "OnCall");
Assert.NotNull(onCall);
// 8b. Exactly one BundleExported audit row (user=alice) survived the
// wipe? No — we deliberately wiped audit rows along with the
// entities. The export-side audit row was written BEFORE the
// wipe. Post-import there should therefore be zero
// BundleExported rows from before, plus none from the import
// (the importer doesn't emit BundleExported). The round-trip's
// export-side identity therefore lives ONLY in the bundle bytes
// we captured — manifestContentHash is the export's hash, and
// the BundleImported row's AfterStateJson echoes it back below.
// So this assertion targets the BundleImported side only.
var imported = await ctx.AuditLogEntries
.SingleAsync(a => a.Action == "BundleImported");
Assert.Equal("bob", imported.User);
Assert.Equal("Bundle", imported.EntityType);
Assert.Equal(result.BundleImportId, imported.BundleImportId);
Assert.NotNull(imported.AfterStateJson);
// Round-trip identity: the import row carries the same ContentHash
// we read from the bundle bytes above. That's the proof that the
// export's bundle is exactly what the import consumed.
Assert.Contains(manifestContentHash, imported.AfterStateJson!, StringComparison.Ordinal);
// 8c. Every per-entity audit row written during the import run
// carries result.BundleImportId — that's the correlation
// contract from T5/T17 applied end-to-end here.
var importRows = await ctx.AuditLogEntries
.Where(a => a.BundleImportId == result.BundleImportId)
.ToListAsync();
Assert.NotEmpty(importRows);
Assert.All(importRows, row =>
Assert.Equal(result.BundleImportId, row.BundleImportId));
// We seeded 3 templates + 1 shared script + 1 external system + 1
// notification list = 6 created artifacts, plus the BundleImported
// summary row = at least 7 stamped rows.
Assert.True(importRows.Count >= 7,
$"Expected at least 7 BundleImportId-stamped rows; got {importRows.Count}.");
}
// 8d. ImportResult counts line up with what we seeded.
Assert.Equal(6, result.Added);
Assert.Equal(0, result.Overwritten);
Assert.Equal(0, result.Skipped);
Assert.NotEqual(Guid.Empty, result.BundleImportId);
}
}
@@ -0,0 +1,280 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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;
/// <summary>
/// FU-C — integration tests for the two-tier semantic validation wired into
/// <see cref="BundleImporter.ApplyAsync"/>: Pass 1 is the minimal name-
/// resolution scan (carried forward from the v1 importer) and Pass 2 is the
/// full <c>SemanticValidator</c> over each imported template's
/// <c>FlattenedConfiguration</c>. Pass 1 fails fast — Pass 2 only runs when
/// Pass 1 succeeds — so the Pass 2 scenarios here are chosen to live entirely
/// in alarm shape (alarm JSON is not scanned by Pass 1).
/// <para>
/// The "invalid call target" test exercises Pass 1 because every
/// SemanticValidator call-target rule presupposes the called identifier is
/// already known to the script body's surface; an entirely-unknown identifier
/// surfaces at Pass 1 first by design. Both tiers throw the same
/// <see cref="SemanticValidationException"/> with errors propagated.
/// </para>
/// </summary>
public sealed class SemanticValidatorImportTests : IDisposable
{
private readonly ServiceProvider _provider;
public SemanticValidatorImportTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
var dbName = $"SemanticValidatorImportTests_{Guid.NewGuid()}";
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();
/// <summary>
/// Export everything currently seeded, wipe the DB, then LoadAsync the
/// bundle. Returns the session id. Mirrors the helper in
/// <c>BundleImporterApplyTests</c> but exported as a free helper so each
/// test can seed its own template shape without sharing fixture state.
/// </summary>
private async Task<Guid> ExportWipeAndLoadAsync()
{
byte[] bundleBytes;
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);
var stream = await exporter.ExportAsync(selection,
user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
// Wipe so the apply is exercising the Add path.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms);
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes);
ctx.Templates.RemoveRange(ctx.Templates);
await ctx.SaveChangesAsync();
}
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
using var input = new MemoryStream(bundleBytes, writable: false);
var session = await importer.LoadAsync(input, passphrase: null);
sessionId = session.SessionId;
}
return sessionId;
}
[Fact]
public async Task SemanticValidator_catches_invalid_call_target_at_import()
{
// Arrange — template whose script body calls UnknownHelper(): a
// PascalCase identifier that doesn't resolve to any SharedScript or
// ExternalSystem in the bundle or the target. This is the operator-
// facing "invalid call target" surface — the full SemanticValidator's
// CallScript/CallShared signature checks live downstream of name
// resolution (you can't check arg count against a function that
// doesn't exist). Pass 1 catches it first and fails fast.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("ScriptCallsUnknown");
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
"init",
"var x = UnknownHelper();"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportWipeAndLoadAsync();
// Act — apply must throw SemanticValidationException carrying the bad
// call target by name.
SemanticValidationException ex = default!;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "ScriptCallsUnknown", ResolutionAction.Add, null),
},
user: "bob"));
}
// Assert — error message names the bad target.
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors,
err => err.Contains("UnknownHelper", StringComparison.Ordinal));
// Rollback — no template row landed.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "ScriptCallsUnknown"));
}
}
[Fact]
public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import()
{
// Arrange — template with a String attribute Status and a
// RangeViolation alarm against it. The full SemanticValidator must
// report TriggerOperandType (RangeViolation requires numeric).
// Pass 1 doesn't scan alarm JSON, so the error reaches Pass 2.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("TankWithBadAlarm") { Description = "RangeViolation on string attr" };
t.Attributes.Add(new TemplateAttribute("Status")
{
DataType = DataType.String,
Value = "OK",
});
t.Alarms.Add(new TemplateAlarm("BadRange")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Status\",\"min\":0,\"max\":100}",
PriorityLevel = 1,
});
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportWipeAndLoadAsync();
// Act
SemanticValidationException ex = default!;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "TankWithBadAlarm", ResolutionAction.Add, null),
},
user: "bob"));
}
// Assert — error names the offending alarm and the bad trigger
// type so the operator can locate the fix.
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors,
err => err.Contains("BadRange", StringComparison.Ordinal)
&& err.Contains("RangeViolation", StringComparison.Ordinal));
// Rollback — no template row landed.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "TankWithBadAlarm"));
}
}
[Fact]
public async Task Valid_bundle_passes_semantic_validation()
{
// Arrange — clean template that satisfies both passes: one Double
// attribute, one ValueMatch alarm on it, one script with no external
// call identifiers. ValueMatch doesn't constrain the operand data
// type (only RangeViolation / HiLo do), so this template's alarm is
// legal.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("CleanPump") { Description = "passes both passes" };
t.Attributes.Add(new TemplateAttribute("Speed")
{
DataType = DataType.Double,
Value = "0",
});
t.Alarms.Add(new TemplateAlarm("Overspeed")
{
TriggerType = AlarmTriggerType.ValueMatch,
TriggerConfiguration = "{\"attributeName\":\"Speed\",\"value\":100}",
PriorityLevel = 1,
});
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
"tick",
"// no external calls"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportWipeAndLoadAsync();
// Act — happy-path import.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "CleanPump", ResolutionAction.Add, null),
},
user: "bob");
}
// Assert — template + alarm survived the round-trip.
Assert.Equal(1, result.Added);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.True(await ctx.Templates.AnyAsync(t => t.Name == "CleanPump"));
Assert.True(await ctx.TemplateAlarms.AnyAsync(a => a.Name == "Overspeed"));
}
}
}
@@ -0,0 +1,174 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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;
/// <summary>
/// T26 — integration validation-failure rollback test. Complements the unit
/// rollback covered by <c>BundleImporterApplyTests</c> by driving the full
/// export → load → apply path, so the failing validation runs against a
/// real export-side serialised bundle.
/// </summary>
public sealed class ValidationFailureTests : IDisposable
{
private readonly ServiceProvider _provider;
public ValidationFailureTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
var dbName = $"ValidationFailureTests_{Guid.NewGuid()}";
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();
[Fact]
public async Task Semantic_validation_failure_rolls_back_all_writes()
{
// Arrange:
// - Seed a template whose script body references MissingHelper(),
// a SharedScript that is NOT in the bundle (we don't seed one) and
// also NOT in the target after the wipe.
// - Export the template only (no helper alongside).
// - Wipe the target so the apply runs through Add-paths.
// - Snapshot row counts so the rollback assertion is unambiguous.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("BrokenPump") { Description = "calls a missing helper" };
t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
// Export only the broken template. IncludeDependencies=false guarantees
// MissingHelper is NOT pulled in even if one happened to exist.
byte[] bundleBytes;
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);
var stream = await exporter.ExportAsync(selection,
user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
// Wipe target so the apply attempts an Add (the validation failure must
// not let it land).
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
ctx.Templates.RemoveRange(ctx.Templates);
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
await ctx.SaveChangesAsync();
}
int templatesBefore;
int sharedScriptsBefore;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
templatesBefore = await ctx.Templates.CountAsync();
sharedScriptsBefore = await ctx.SharedScripts.CountAsync();
}
Assert.Equal(0, templatesBefore);
Assert.Equal(0, sharedScriptsBefore);
// Load the bundle into a session.
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
using var input = new MemoryStream(bundleBytes, writable: false);
var session = await importer.LoadAsync(input, passphrase: null);
sessionId = session.SessionId;
}
// Act — apply with Add. Validation MUST throw.
SemanticValidationException ex = default!;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "BrokenPump", ResolutionAction.Add, null),
},
user: "bob"));
}
// Assert
// 1. Errors list calls out the missing dependency by name.
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors,
err => err.Contains("MissingHelper", StringComparison.Ordinal));
// 2. No new Template or SharedScript row was committed.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.Equal(templatesBefore, await ctx.Templates.CountAsync());
Assert.Equal(sharedScriptsBefore, await ctx.SharedScripts.CountAsync());
// 3. A BundleImportFailed audit row exists. The BundleImporter
// contract (T17) is: failure row's BundleImportId is NULL (the
// rolled-back id is intentionally disowned) and its
// AfterStateJson.Reason calls out the validation failure.
var failure = await ctx.AuditLogEntries
.SingleAsync(a => a.Action == "BundleImportFailed");
Assert.Equal("Bundle", failure.EntityType);
Assert.Null(failure.BundleImportId);
Assert.NotNull(failure.AfterStateJson);
// The exception message lands in the Reason field of the payload
// (BundleImporter.ApplyAsync wires it through). Spot-check for
// "validation" so the row's correlation to the failure is visible.
Assert.Contains("validation", failure.AfterStateJson!,
StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj" />
</ItemGroup>
</Project>