fix(transport): connection map Pass-2 (FK) + site-qualified connection resolution (M8 D1-FIX, C1+C2)

This commit is contained in:
Joseph Doherty
2026-06-18 07:08:31 -04:00
parent 6457f03fae
commit d974477e87
3 changed files with 507 additions and 16 deletions
@@ -569,7 +569,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
// missing template would block — but we wiped the template too, so the
// instance also blocks; assert the New site/connection at minimum).
Assert.Contains(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1" && i.Kind == ConflictKind.New);
Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary" && i.Kind == ConflictKind.New);
// C2: DataConnection preview items are site-qualified ({site}/{name}).
Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/OpcUaPrimary" && i.Kind == ConflictKind.New);
}
[Fact]
@@ -600,13 +601,66 @@ public sealed class BundleImporterPreviewTests : IDisposable
// The site + connection match the target exactly → Identical, not New.
var siteItem = Assert.Single(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1");
Assert.Equal(ConflictKind.Identical, siteItem.Kind);
var connItem = Assert.Single(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary");
// C2: DataConnection preview items are site-qualified ({site}/{name}).
var connItem = Assert.Single(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/OpcUaPrimary");
Assert.Equal(ConflictKind.Identical, connItem.Kind);
// No blocker — the template + connection both resolve in the target.
Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance");
}
[Fact]
public async Task PreviewAsync_two_sites_same_connection_name_emit_two_distinct_site_qualified_items()
{
// C2: a bundle with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must surface
// TWO distinct DataConnection preview items, each site-qualified
// ({site}/{name}) — NOT a single collapsed "OpcUaPrimary" item. This is what
// lets the operator (and the apply path) resolve each site's connection
// independently. Hand-pack the bundle so both same-named connections are
// present without a heavy two-site export.
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>())
{
Sites = new[]
{
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
new SiteDto("plant-2", "Plant 2", null, null, null, null, null),
},
DataConnections = new[]
{
new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 3, null),
new DataConnectionDto("plant-2", "OpcUaPrimary", "OpcUa", 3, null),
},
Instances = Array.Empty<InstanceDto>(),
};
var bytes = await PackBundleAsync(content);
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);
}
var connItems = preview.Items
.Where(i => i.EntityType == "DataConnection")
.Select(i => i.Name)
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
// Two distinct site-qualified items — NOT one bare "OpcUaPrimary".
Assert.Equal(new[] { "plant-1/OpcUaPrimary", "plant-2/OpcUaPrimary" }, connItems);
Assert.DoesNotContain(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary");
}
[Fact]
public async Task PreviewAsync_modified_instance_against_hydrated_target_shows_child_diff_not_all_added()
{
@@ -714,10 +768,11 @@ public sealed class BundleImporterPreviewTests : IDisposable
preview = await importer.PreviewAsync(session.SessionId);
}
// The unresolved connection blocks…
// The unresolved connection blocks… (C2: blocker Name is site-qualified
// {site}/{name} so two sites' same-named missing connections don't collide).
Assert.Contains(preview.Items, i =>
i.Kind == ConflictKind.Blocker
&& i.Name == "PhantomConn"
&& i.Name == "plant-1/PhantomConn"
&& i.BlockerReason is not null
&& i.BlockerReason.Contains("PhantomConn", StringComparison.Ordinal));
// …but the template resolves in the target, so the instance is NOT a
@@ -270,7 +270,8 @@ public sealed class SiteInstanceImportTests : IDisposable
{
new("Template", "Pump", ResolutionAction.Skip, null), // already present
new("Site", "plant-1", ResolutionAction.Add, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Add, null),
// C2: DataConnection resolutions are keyed by the site-qualified name.
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
@@ -378,7 +379,8 @@ public sealed class SiteInstanceImportTests : IDisposable
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Skip, null), // leave target site untouched
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null), // leave target conn untouched
// C2: DataConnection resolutions are keyed by the site-qualified name.
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null), // leave target conn untouched
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
@@ -499,7 +501,8 @@ public sealed class SiteInstanceImportTests : IDisposable
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Skip, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null),
// C2: DataConnection resolution is keyed by the SOURCE site-qualified name.
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
@@ -582,7 +585,7 @@ public sealed class SiteInstanceImportTests : IDisposable
new List<ImportResolution>
{
new("Site", "plant-1", ResolutionAction.Add, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Add, null),
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
user: "bob",
@@ -641,7 +644,8 @@ public sealed class SiteInstanceImportTests : IDisposable
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Skip, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null),
// C2: DataConnection resolution is keyed by the site-qualified name.
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Overwrite, null),
},
nameMap);
@@ -666,4 +670,317 @@ public sealed class SiteInstanceImportTests : IDisposable
Assert.Equal(1, result.Overwritten);
}
// ──────────────────────────────────────────────────────────────────────
// C1 — binding references a TARGET connection the bundle did NOT carry
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_binding_to_target_connection_omitted_from_bundle_resolves_to_existing_id_not_zero()
{
// C1 regression: a valid bundle can carry an instance whose binding (and
// native-alarm-source override) references a connection that exists in the
// TARGET but was NOT carried in the bundle's DataConnections. Preview does
// NOT block it (it auto-matches the target). Before the C1 Pass-2 fix the
// connection map MISSED for that binding → DataConnectionId defaulted to 0
// (an invalid FK). After the fix the map resolves the EXISTING target
// connection's id and both the binding + the native-alarm override rewrite
// correctly. Hand-pack the bundle so DataConnections is empty while the
// instance still references the connection by name.
int targetConnId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
var site = new Site("Plant 1", "plant-1");
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
var conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target-existing\"}",
};
ctx.DataConnections.Add(conn);
await ctx.SaveChangesAsync();
targetConnId = conn.Id;
}
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>())
{
// Site carried, but the connection is DELIBERATELY OMITTED from the bundle.
Sites = new[]
{
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
},
DataConnections = Array.Empty<DataConnectionDto>(),
Instances = new[]
{
new InstanceDto(
UniqueName: "Pump-01",
TemplateName: "Pump",
SiteIdentifier: "plant-1",
AreaName: null,
State: InstanceState.Enabled,
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
NativeAlarmSourceOverrides: new[]
{
new InstanceNativeAlarmSourceOverrideDto(
SourceCanonicalName: "NativeSrc",
ConnectionNameOverride: "OpcUaPrimary",
SourceReferenceOverride: "ns=3;s=Pump.Alarm",
ConditionFilterOverride: null),
},
ConnectionBindings: new[]
{
new InstanceConnectionBindingDto(
AttributeName: "Flow",
ConnectionName: "OpcUaPrimary",
DataSourceReferenceOverride: "ns=3;s=Pump.Flow"),
}),
},
};
var sessionId = await PackAndLoadAsync(content);
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
// No explicit connection mapping — the connection auto-matches the
// existing target connection within plant-1 (the C1 Pass-2 path).
Connections: Array.Empty<ConnectionMapping>());
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Site", "plant-1", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// No new connection was created — the omitted-from-bundle connection
// auto-matched the EXISTING target row.
var conn = Assert.Single(await ctx.DataConnections.Where(c => c.Name == "OpcUaPrimary").ToListAsync());
Assert.Equal(targetConnId, conn.Id);
var inst = await ctx.Instances
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.SingleAsync(i => i.UniqueName == "Pump-01");
// THE FIX: binding FK points at the EXISTING target connection id — NOT 0.
var binding = Assert.Single(inst.ConnectionBindings);
Assert.NotEqual(0, binding.DataConnectionId);
Assert.Equal(targetConnId, binding.DataConnectionId);
// Native-alarm-source override connection name carries through.
Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
}
Assert.Equal(1, result.Added);
}
[Fact]
public async Task ApplyAsync_binding_to_connection_in_neither_bundle_nor_target_fails_instead_of_writing_zero()
{
// C1 guard: a binding naming a connection present in NEITHER the bundle nor
// the target must FAIL the import (caught in the pre-write validation phase
// as a SemanticValidationException) rather than silently write
// DataConnectionId = 0. Nothing may persist.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
ctx.Sites.Add(new Site("Plant 1", "plant-1"));
await ctx.SaveChangesAsync();
}
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>())
{
DataConnections = Array.Empty<DataConnectionDto>(),
Instances = new[]
{
new InstanceDto(
UniqueName: "Pump-01",
TemplateName: "Pump",
SiteIdentifier: "plant-1",
AreaName: null,
State: InstanceState.Enabled,
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
ConnectionBindings: new[]
{
new InstanceConnectionBindingDto(
AttributeName: "Flow",
ConnectionName: "GhostConn",
DataSourceReferenceOverride: null),
}),
},
};
var sessionId = await PackAndLoadAsync(content);
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
Connections: Array.Empty<ConnectionMapping>());
await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Site", "plant-1", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
user: "bob",
ct: CancellationToken.None,
nameMap: nameMap));
}
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// No instance with a zero (or any) binding persisted.
Assert.Equal(0, await ctx.Instances.CountAsync());
Assert.Equal(0, await ctx.InstanceConnectionBindings.CountAsync());
}
}
// ──────────────────────────────────────────────────────────────────────
// C2 — two sites with same-named connections resolve independently
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_two_sites_same_connection_name_apply_per_site_resolution_independently()
{
// C2 regression: connection names are unique only WITHIN a site, so a bundle
// with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must NOT collapse onto a
// single resolution. Both target connections exist; the operator Overwrites
// plant-1's and Skips plant-2's. The site-qualified resolution key routes the
// Overwrite to plant-1's connection ONLY and leaves plant-2's untouched.
int plant1ConnId, plant2ConnId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var s1 = new Site("Plant 1", "plant-1");
var s2 = new Site("Plant 2", "plant-2");
ctx.Sites.AddRange(s1, s2);
await ctx.SaveChangesAsync();
var c1 = new DataConnection("OpcUaPrimary", "OpcUa", s1.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://p1-existing\"}",
FailoverRetryCount = 1,
};
var c2 = new DataConnection("OpcUaPrimary", "OpcUa", s2.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://p2-existing\"}",
FailoverRetryCount = 2,
};
ctx.DataConnections.AddRange(c1, c2);
await ctx.SaveChangesAsync();
plant1ConnId = c1.Id;
plant2ConnId = c2.Id;
}
// Hand-pack a bundle carrying both same-named connections, each with a
// DISTINCT incoming protocol-config so an Overwrite is observable.
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>())
{
Sites = new[]
{
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
new SiteDto("plant-2", "Plant 2", null, null, null, null, null),
},
DataConnections = new[]
{
new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 9,
new SecretsBlock(new Dictionary<string, string>
{
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://p1-from-bundle\"}",
})),
new DataConnectionDto("plant-2", "OpcUaPrimary", "OpcUa", 9,
new SecretsBlock(new Dictionary<string, string>
{
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://p2-from-bundle\"}",
})),
},
Instances = Array.Empty<InstanceDto>(),
};
var sessionId = await PackAndLoadAsync(content);
var nameMap = new BundleNameMap(
Sites: new[]
{
new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1"),
new SiteMapping("plant-2", MappingAction.MapToExisting, "plant-2"),
},
Connections: new[]
{
new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary"),
new ConnectionMapping("plant-2", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary"),
});
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Site", "plant-1", ResolutionAction.Skip, null),
new("Site", "plant-2", ResolutionAction.Skip, null),
// Per-site resolutions — keyed by the site-qualified name (C2).
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Overwrite, null),
new("DataConnection", "plant-2/OpcUaPrimary", ResolutionAction.Skip, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// plant-1's connection was Overwritten with the bundle's config.
var c1 = await ctx.DataConnections.SingleAsync(c => c.Id == plant1ConnId);
Assert.Contains("p1-from-bundle", c1.PrimaryConfiguration!);
Assert.Equal(9, c1.FailoverRetryCount);
// plant-2's same-named connection was LEFT UNTOUCHED by the Skip — the
// bare-name collision bug would have applied plant-1's Overwrite here too.
var c2 = await ctx.DataConnections.SingleAsync(c => c.Id == plant2ConnId);
Assert.Contains("p2-existing", c2.PrimaryConfiguration!);
Assert.Equal(2, c2.FailoverRetryCount);
// Still exactly two connections — no duplicates created.
Assert.Equal(2, await ctx.DataConnections.CountAsync(c => c.Name == "OpcUaPrimary"));
}
// One Overwrite (plant-1 conn), three Skips (two sites + plant-2 conn).
Assert.Equal(1, result.Overwritten);
Assert.Equal(3, result.Skipped);
}
}