fix(m9/T24a): scope move-guard native-alarm scan to source-site templates (Ordinal); purpose-built include; add guard-4 + repo tests
This commit is contained in:
@@ -90,6 +90,18 @@ public interface ISiteRepository
|
||||
/// <returns>A task that resolves to the distinct referencing <see cref="Instance"/> entities (empty when none).</returns>
|
||||
Task<IReadOnlyList<Instance>> GetInstancesReferencingDataConnectionAsync(int dataConnectionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a site's instances with their
|
||||
/// <see cref="Instance.NativeAlarmSourceOverrides"/> eagerly loaded. Purpose-built
|
||||
/// for the move-data-connection guard, which must scan source-site instance
|
||||
/// overrides for name-based connection references without paying the eager-load
|
||||
/// cost on the hot-path <see cref="GetInstancesBySiteIdAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The site primary key to filter by.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the site's <see cref="Instance"/> entities with overrides loaded (empty when none).</returns>
|
||||
Task<IReadOnlyList<Instance>> GetInstancesWithNativeAlarmOverridesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Saves all pending changes to the database.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the number of state entries written to the database.</returns>
|
||||
|
||||
@@ -132,9 +132,15 @@ public class SiteRepository : ISiteRepository
|
||||
{
|
||||
return await _dbContext.Instances
|
||||
.Where(i => i.SiteId == siteId)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesWithNativeAlarmOverridesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Instances
|
||||
.Where(i => i.SiteId == siteId)
|
||||
.Include(i => i.NativeAlarmSourceOverrides)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -1426,17 +1426,40 @@ public class ManagementActor : ReceiveActor
|
||||
// instances may override that name
|
||||
// (InstanceNativeAlarmSourceOverride.ConnectionNameOverride). Moving a
|
||||
// connection changes which physical connection that name resolves to on
|
||||
// the source site, so any such reference to the moving name is a blocker.
|
||||
// Detect and report only — never auto-rewrite.
|
||||
// the SOURCE site, so any such reference *on the source site* to the
|
||||
// moving name is a blocker. Detect and report only — never auto-rewrite.
|
||||
//
|
||||
// Scoping: templates are site-agnostic and ConnectionName resolves
|
||||
// against the DEPLOYING site's connection pool at flatten time, so the
|
||||
// template scan is restricted to templates actually instantiated on the
|
||||
// SOURCE site. A template referencing the same connection NAME but only
|
||||
// instantiated on another site is NOT affected by this move (that site
|
||||
// keeps its own connection of the same name), so a global template scan
|
||||
// would be a false positive that over-blocks legitimate moves whenever
|
||||
// connection names are reused across sites (the common case).
|
||||
//
|
||||
// Comparison is StringComparison.Ordinal to match the
|
||||
// flattening/deployment pipeline (FlatteningService / FlatteningPipeline
|
||||
// resolve connection names case-sensitively) — block exactly the
|
||||
// references the runtime would actually fail to resolve.
|
||||
var referenceBlockers = new List<string>();
|
||||
|
||||
// Source-site instances with their native-alarm-source overrides eagerly
|
||||
// loaded (purpose-built load so the hot-path GetInstancesBySiteIdAsync
|
||||
// stays lean). Reused below for both the override scan and to derive the
|
||||
// set of templates to scan.
|
||||
var sourceInstances = await repo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(sourceSiteId);
|
||||
|
||||
// Only templates instantiated on the SOURCE site can be broken by the move.
|
||||
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var templates = await templateRepo.GetAllTemplatesAsync();
|
||||
foreach (var template in templates)
|
||||
var sourceTemplateIds = sourceInstances.Select(i => i.TemplateId).Distinct();
|
||||
foreach (var templateId in sourceTemplateIds)
|
||||
{
|
||||
var template = await templateRepo.GetTemplateByIdAsync(templateId);
|
||||
if (template is null) continue;
|
||||
foreach (var source in template.NativeAlarmSources)
|
||||
{
|
||||
if (string.Equals(source.ConnectionName, conn.Name, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(source.ConnectionName, conn.Name, StringComparison.Ordinal))
|
||||
{
|
||||
referenceBlockers.Add(
|
||||
$"template '{template.Name}' native-alarm-source '{source.Name}'");
|
||||
@@ -1446,12 +1469,11 @@ public class ManagementActor : ReceiveActor
|
||||
|
||||
// Instance-level overrides on the SOURCE site that name this connection
|
||||
// would orphan once the connection leaves the site.
|
||||
var sourceInstances = await repo.GetInstancesBySiteIdAsync(sourceSiteId);
|
||||
foreach (var instance in sourceInstances)
|
||||
{
|
||||
foreach (var ovr in instance.NativeAlarmSourceOverrides)
|
||||
{
|
||||
if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.Ordinal))
|
||||
{
|
||||
referenceBlockers.Add(
|
||||
$"instance '{instance.UniqueName}' (ID {instance.Id}) native-alarm-source override '{ovr.SourceCanonicalName}'");
|
||||
|
||||
@@ -776,6 +776,76 @@ public class SiteRepositoryTests : IDisposable
|
||||
Assert.Single(instances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInstancesReferencingDataConnection_NoBindings_ReturnsEmpty()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
|
||||
_context.DataConnections.Add(conn);
|
||||
_context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInstancesReferencingDataConnection_OneBinding_ReturnsThatInstance()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
|
||||
_context.DataConnections.Add(conn);
|
||||
var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.InstanceConnectionBindings.Add(
|
||||
new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(instance.Id, result[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInstancesReferencingDataConnection_TwoBindingsSameInstance_ReturnsOne()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
|
||||
_context.DataConnections.Add(conn);
|
||||
var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Two distinct bindings (different attributes) on the SAME instance both
|
||||
// reference the connection -> the result must be deduped to one instance.
|
||||
_context.InstanceConnectionBindings.Add(
|
||||
new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id });
|
||||
_context.InstanceConnectionBindings.Add(
|
||||
new InstanceConnectionBinding("Attr2") { InstanceId = instance.Id, DataConnectionId = conn.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(instance.Id, result[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
|
||||
@@ -2104,18 +2104,14 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
// No bindings reference the connection -> move allowed.
|
||||
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance>());
|
||||
// No native-alarm-source overrides on the source site.
|
||||
siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
// No native-alarm-source overrides on the source site (purpose-built load).
|
||||
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance>());
|
||||
siteRepo.UpdateDataConnectionAsync(Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
// No templates reference the connection by name.
|
||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Template>());
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
|
||||
|
||||
@@ -2213,4 +2209,173 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
|
||||
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveDataConnection_SameSite_ReturnsErrorNoWrite()
|
||||
{
|
||||
// Moving a connection to the site it already belongs to is a no-op and
|
||||
// must be rejected before any write — and before the existence/collision
|
||||
// guards even run.
|
||||
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new MoveDataConnectionCommand(5, 1), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("already belongs", response.Error);
|
||||
Assert.Equal(1, conn.SiteId);
|
||||
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
|
||||
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
|
||||
siteRepo.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveDataConnection_BlockedByTemplateNativeAlarmSourceOnSourceSite_ReturnsError()
|
||||
{
|
||||
// A template instantiated on the SOURCE site has a native-alarm-source
|
||||
// whose ConnectionName matches the moving connection -> blocked, because
|
||||
// the deploying-site flatten would no longer resolve that name.
|
||||
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
|
||||
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
|
||||
|
||||
// Source-site instance referencing template 42.
|
||||
var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 42 };
|
||||
|
||||
var template = new Template("PumpTemplate") { Id = 42 };
|
||||
template.NativeAlarmSources.Add(
|
||||
new TemplateNativeAlarmSource("PumpFaults") { Id = 1, TemplateId = 42, ConnectionName = "Opc1" });
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
|
||||
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
|
||||
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Sites.DataConnection>());
|
||||
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance>());
|
||||
// Source-site instances (purpose-built load with overrides eagerly loaded).
|
||||
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance> { sourceInstance });
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
// Only template 42 (instantiated on the source site) is scanned.
|
||||
_templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>()).Returns(template);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
// Error names the template and its native-alarm-source.
|
||||
Assert.Contains("PumpTemplate", response.Error);
|
||||
Assert.Contains("PumpFaults", response.Error);
|
||||
Assert.Equal(1, conn.SiteId);
|
||||
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
|
||||
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveDataConnection_BlockedByInstanceOverrideOnSourceSite_ReturnsError()
|
||||
{
|
||||
// A source-site instance has a native-alarm-source override whose
|
||||
// ConnectionNameOverride matches the moving connection -> blocked.
|
||||
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
|
||||
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
|
||||
|
||||
var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 42 };
|
||||
sourceInstance.NativeAlarmSourceOverrides.Add(
|
||||
new InstanceNativeAlarmSourceOverride("PumpFaults")
|
||||
{ Id = 1, InstanceId = 7, ConnectionNameOverride = "Opc1" });
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
|
||||
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
|
||||
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Sites.DataConnection>());
|
||||
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance>());
|
||||
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance> { sourceInstance });
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
// Template 42 has no matching native-alarm-source; the override is the blocker.
|
||||
_templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>())
|
||||
.Returns(new Template("PumpTemplate") { Id = 42 });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
// Error names the instance and its override.
|
||||
Assert.Contains("Tank01", response.Error);
|
||||
Assert.Contains("PumpFaults", response.Error);
|
||||
Assert.Equal(1, conn.SiteId);
|
||||
siteRepo.DidNotReceive().UpdateDataConnectionAsync(
|
||||
Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoveDataConnection_TemplateReferencedOnlyByOtherSite_DoesNotBlock()
|
||||
{
|
||||
// Regression for the over-blocking false positive: a template references
|
||||
// the connection NAME ("Opc1") but is instantiated ONLY on another site.
|
||||
// Templates are site-agnostic and ConnectionName resolves against the
|
||||
// DEPLOYING site's pool, so moving site 1's "Opc1" does not break the
|
||||
// other site's deployment. The move must succeed.
|
||||
var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
|
||||
var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
|
||||
|
||||
// The SOURCE site (1) has an instance using template 99, which does NOT
|
||||
// reference "Opc1". A different template (42) referencing "Opc1" exists
|
||||
// but is instantiated only on the target/other site, so it is never loaded.
|
||||
var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 99 };
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
|
||||
siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
|
||||
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Sites.DataConnection>());
|
||||
siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance>());
|
||||
siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance> { sourceInstance });
|
||||
siteRepo.UpdateDataConnectionAsync(Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
// Template 99 (the one actually on the source site) does not reference "Opc1".
|
||||
_templateRepo.GetTemplateByIdAsync(99, Arg.Any<CancellationToken>())
|
||||
.Returns(new Template("OtherTemplate") { Id = 99 });
|
||||
// Template 42 references "Opc1" but lives only on another site: if the
|
||||
// handler scoped its scan correctly it must NEVER load template 42. We
|
||||
// still stub it to prove the cross-site reference is irrelevant.
|
||||
var crossSiteTemplate = new Template("PumpTemplate") { Id = 42 };
|
||||
crossSiteTemplate.NativeAlarmSources.Add(
|
||||
new TemplateNativeAlarmSource("PumpFaults") { Id = 1, TemplateId = 42, ConnectionName = "Opc1" });
|
||||
_templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>()).Returns(crossSiteTemplate);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
// Move SUCCEEDS: the cross-site template reference must not block.
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.Equal(2, conn.SiteId);
|
||||
siteRepo.Received(1).UpdateDataConnectionAsync(
|
||||
Arg.Is<Commons.Entities.Sites.DataConnection>(c => c.Id == 5 && c.SiteId == 2),
|
||||
Arg.Any<CancellationToken>());
|
||||
// The off-site template (42) must never be scanned.
|
||||
_templateRepo.DidNotReceive().GetTemplateByIdAsync(42, Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user