fix(uns): enforce #122 on line reparent across clusters (final review)
v2-ci / build (push) Failing after 4m38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

This commit is contained in:
Joseph Doherty
2026-06-08 14:12:46 -04:00
parent 1bb7482c3a
commit 8ba64b1d99
2 changed files with 122 additions and 0 deletions
@@ -408,6 +408,41 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// Decision #122: a reparent to a different area must not orphan driver-bound equipment
// from its driver's cluster. Resolve the new area's cluster and check every bound
// equipment item under this line against it.
if (newUnsAreaId != entity.UnsAreaId)
{
var newAreaCluster = await db.UnsAreas
.Where(a => a.UnsAreaId == newUnsAreaId)
.Select(a => (string?)a.ClusterId)
.FirstOrDefaultAsync(ct);
if (newAreaCluster is not null)
{
var boundEquipment = await db.Equipment
.Where(eq => eq.UnsLineId == unsLineId && eq.DriverInstanceId != null)
.Select(eq => new { eq.EquipmentId, eq.DriverInstanceId })
.ToListAsync(ct);
foreach (var eq in boundEquipment)
{
var driverCluster = await db.DriverInstances
.Where(d => d.DriverInstanceId == eq.DriverInstanceId)
.Select(d => d.ClusterId)
.FirstOrDefaultAsync(ct);
if (driverCluster is not null && driverCluster != newAreaCluster)
{
return new UnsMutationResult(
false,
$"Cannot move line to area '{newUnsAreaId}': equipment '{eq.EquipmentId}' is bound to a driver in cluster '{driverCluster}' (decision #122). Re-home or unbind it first.");
}
}
}
}
entity.UnsAreaId = newUnsAreaId;
entity.Name = name;
entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;
@@ -337,6 +337,93 @@ public sealed class UnsTreeServiceAreaLineTests
result.Error.ShouldBe("Row no longer exists.");
}
/// <summary>The #122 guard blocks reparenting a line to an area in a different cluster when
/// the line's equipment is driver-bound (the driver lives in the original cluster).</summary>
[Fact]
public async Task UpdateLine_reparent_to_other_cluster_blocked_when_driver_bound()
{
var (service, dbName) = Fresh();
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
// Source area A1 in cluster MAIN.
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A1-MAIN", ClusterId = "MAIN", Name = "area-main" });
// Target area A2 in cluster SITE-A.
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A2-SITE-A", ClusterId = "SITE-A", Name = "area-site-a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-BOUND", UnsAreaId = "A1-MAIN", Name = "line" });
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-MAIN-122",
ClusterId = "MAIN",
NamespaceId = "NS-1",
Name = "drv",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
db.Equipment.Add(new Equipment
{
EquipmentId = "EQ-LINE-BOUND",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-BOUND",
Name = "eq",
MachineCode = "mc_line_bound",
DriverInstanceId = "DRV-MAIN-122",
});
db.SaveChanges();
rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").RowVersion;
}
var result = await service.UpdateLineAsync("LINE-BOUND", "line", null, "A2-SITE-A", rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("EQ-LINE-BOUND");
result.Error.ShouldContain("A2-SITE-A");
result.Error.ShouldContain("MAIN");
// The line must not have moved.
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").UnsAreaId.ShouldBe("A1-MAIN");
}
/// <summary>The #122 guard allows reparenting a line to an area in a different cluster when
/// the line's equipment is driver-less (DriverInstanceId == null).</summary>
[Fact]
public async Task UpdateLine_reparent_to_other_cluster_allowed_when_equipment_driverless()
{
var (service, dbName) = Fresh();
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A1-FREE", ClusterId = "MAIN", Name = "area-main" });
db.UnsAreas.Add(new UnsArea { UnsAreaId = "A2-FREE", ClusterId = "SITE-A", Name = "area-site-a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-FREE", UnsAreaId = "A1-FREE", Name = "line" });
db.Equipment.Add(new Equipment
{
EquipmentId = "EQ-LINE-FREE",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-FREE",
Name = "eq",
MachineCode = "mc_line_free",
DriverInstanceId = null,
});
db.SaveChanges();
rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-FREE").RowVersion;
}
var result = await service.UpdateLineAsync("LINE-FREE", "line", null, "A2-FREE", rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
// The line's UnsAreaId must have changed to A2-FREE.
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.UnsLines.Single(l => l.UnsLineId == "LINE-FREE").UnsAreaId.ShouldBe("A2-FREE");
}
// ----- DeleteLine -----
/// <summary>Deleting a line removes the row.</summary>