fix(transport): flush created-site ids before the connection pass — create-missing import failed FK 547 on real SQL Server

Caught live 2026-08-01 importing a bundle into the empty env2 cluster: a
create-missing Site has Id == 0 until SaveChanges on a relational provider,
and ApplyDataConnectionsAsync stamps DataConnection.SiteId as a raw scalar
(no navigation, no EF fix-up), so the insert violated
FK_DataConnections_Sites_SiteId. Every importer integration test runs on
the EF in-memory provider, which assigns ids eagerly on AddAsync — the
exact masking the in-code comment predicted. Fix: one SaveChangesAsync
between the site pass and the connection pass, riding the same outer
transaction (all-or-nothing preserved; the failed live import rolled back
cleanly). Regression test runs the create-missing path on SQLite
(CreateMissingSiteRelationalTests) — red without the fix, green with it.
This commit is contained in:
Joseph Doherty
2026-08-01 12:32:45 -04:00
parent 1c99d6fa8d
commit 0c9dffed78
2 changed files with 168 additions and 0 deletions
@@ -1515,6 +1515,14 @@ public sealed class BundleImporter : IBundleImporter
// ids by name.) // ids by name.)
var siteBySourceIdentifier = await ApplySitesAsync( var siteBySourceIdentifier = await ApplySitesAsync(
content, nameMap, resolutionMap, user, summary, ct).ConfigureAwait(false); content, nameMap, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Flush BETWEEN the site pass and the connection pass: a create-missing
// site has Id == 0 until SaveChanges on a relational provider, and the
// connection pass stamps DataConnection.SiteId as a raw scalar (no EF
// navigation, so no fix-up) — without this flush the insert fails the
// FK on real SQL Server (FK_DataConnections_Sites_SiteId, error 547).
// The in-memory provider assigns ids eagerly on AddAsync, which is why
// in-process tests never hit this. Rides the same outer transaction.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
var connectionMaps = await ApplyDataConnectionsAsync( var connectionMaps = await ApplyDataConnectionsAsync(
content, nameMap, siteBySourceIdentifier, resolutionMap, user, summary, ct).ConfigureAwait(false); content, nameMap, siteBySourceIdentifier, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Flush so site + connection surrogate ids are assigned (relational // Flush so site + connection surrogate ids are assigned (relational
@@ -0,0 +1,160 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
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>
/// Regression guard for the create-missing site/connection apply path on a REAL
/// relational provider (SQLite). Every other importer integration test runs on the
/// EF in-memory provider, which assigns surrogate ids eagerly on <c>AddAsync</c> —
/// masking that a create-missing <c>Site</c> still has <c>Id == 0</c> until the
/// context is flushed. <c>ApplyDataConnectionsAsync</c> stamps
/// <c>DataConnection.SiteId</c> as a raw scalar (no EF navigation, so no fix-up),
/// so without a flush between the site pass and the connection pass the insert
/// fails the FK on a relational provider (live failure 2026-08-01: SQL Server
/// error 547, <c>FK_DataConnections_Sites_SiteId</c>, on a cross-environment
/// bundle import into an empty target).
/// </summary>
public sealed class CreateMissingSiteRelationalTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
public CreateMissingSiteRelationalTests()
{
// One open in-memory SQLite connection for the fixture's lifetime — the
// database lives exactly as long as the connection.
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseSqlite(_connection));
// Secret-bearing columns require the encrypting two-arg ctor — same wiring
// as RoundTripTests / BundleImporterRollbackFailureTests.
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
services.AddScoped(sp => new ScadaBridgeDbContext(
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<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
_provider = services.BuildServiceProvider();
using var scope = _provider.CreateScope();
scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Database.EnsureCreated();
}
public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
}
[Fact]
public async Task Import_create_missing_site_and_connection_wires_the_materialised_site_id()
{
// ---- Seed a source site + connection, export the site closure. ----
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var site = new Site("Plant 1", "plant-1")
{
Description = "source plant",
NodeAAddress = "akka://site@10.0.0.1:2552",
NodeBAddress = "akka://site@10.0.0.2:2552",
GrpcNodeAAddress = "10.0.0.1:8083",
GrpcNodeBAddress = "10.0.0.2:8083",
};
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
ctx.DataConnections.Add(new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
FailoverRetryCount = 5,
});
await ctx.SaveChangesAsync();
}
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
var bundleStream = await exporter.ExportAsync(
new ExportSelection(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true,
SiteIds: siteIds),
user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
using var ms = new MemoryStream();
await bundleStream.CopyToAsync(ms);
ms.Position = 0;
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(ms, passphrase: null);
sessionId = session.SessionId;
}
// ---- Wipe the target so the apply exercises CreateNew on both. ----
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.DataConnections.RemoveRange(ctx.DataConnections);
ctx.Sites.RemoveRange(ctx.Sites);
await ctx.SaveChangesAsync();
}
// ---- Apply with create-missing mappings (the CLI's --create-missing-*). ----
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var nameMap = new BundleNameMap(
Sites: [new SiteMapping("plant-1", MappingAction.CreateNew, null)],
Connections: [new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null)]);
await importer.ApplyAsync(
sessionId, Array.Empty<ImportResolution>(), user: "bob",
ct: CancellationToken.None, nameMap: nameMap);
}
// ---- The created connection must reference the created site's REAL id. ----
await using (var verify = _provider.CreateAsyncScope())
{
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var site = Assert.Single(await ctx.Sites.ToListAsync());
var conn = Assert.Single(await ctx.DataConnections.ToListAsync());
Assert.True(site.Id > 0, "created site id was never materialised");
Assert.Equal(site.Id, conn.SiteId);
}
}
}