From 53116bdd8374006e5f9e96121307c5dae4223622 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 21:17:45 -0400 Subject: [PATCH] feat(adminui): CreateAliasTagAsync/UpdateAliasTagAsync + Galaxy-gateway guard --- .../Uns/IUnsTreeService.cs | 27 +++ .../Uns/UnsTreeService.cs | 154 +++++++++++++ .../Uns/UnsTreeServiceAliasTagTests.cs | 205 ++++++++++++++++++ 3 files changed, 386 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index 8209ceb6..f9934fa5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -379,6 +379,33 @@ public interface IUnsTreeService /// Success, a missing-row failure, a guard failure, or a concurrency failure. Task UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default); + /// + /// Creates a new Galaxy alias tag on an equipment: an ordinary equipment-bound Tag bound to a + /// GalaxyMxGateway driver, with FolderPath null and a {"FullName":…} TagConfig + /// carrying the picked Galaxy reference. Fails on a duplicate TagId, an empty Galaxy reference, + /// an unknown equipment, a driver that is not a Galaxy gateway in the equipment's cluster, or a name + /// already used on the equipment. + /// + /// The owning equipment. + /// The operator-editable alias fields (id, name, gateway, type, access, reference). + /// A token to cancel the operation. + /// Success, or one of the guard failures. + Task CreateAliasTagAsync(string equipmentId, AliasTagInput input, CancellationToken ct = default); + + /// + /// Updates a Galaxy alias tag's gateway binding, name, data type, access level, and Galaxy reference + /// (the {"FullName":…} TagConfig). The owning EquipmentId and the null FolderPath + /// are preserved. Re-runs the empty-reference and Galaxy-gateway-in-cluster guards against the alias's + /// existing equipment, and enforces name uniqueness on that equipment excluding this tag. Uses + /// last-write-wins optimistic concurrency on . + /// + /// The alias tag to update. + /// The new operator-editable alias fields. + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a missing-row failure, a guard failure, or a concurrency failure. + Task UpdateAliasTagAsync(string tagId, AliasTagInput input, byte[] rowVersion, CancellationToken ct = default); + /// /// Deletes a tag. A missing row is treated as success (already gone). Uses last-write-wins /// optimistic concurrency on . diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 62d82b50..55d9a146 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -893,6 +893,101 @@ public sealed class UnsTreeService(IDbContextFactory dbF } } + /// + public async Task CreateAliasTagAsync( + string equipmentId, + AliasTagInput input, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (await db.Tags.AnyAsync(t => t.TagId == input.TagId, ct)) + { + return new UnsMutationResult(false, $"Tag '{input.TagId}' already exists."); + } + + if (string.IsNullOrWhiteSpace(input.FullName)) + { + return new UnsMutationResult(false, "Alias is missing a Galaxy reference."); + } + + if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct)) + { + return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found."); + } + + var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct); + var guard = await CheckAliasDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct); + if (guard is not null) + { + return guard.Value; + } + + if (await db.Tags.AnyAsync(t => t.EquipmentId == equipmentId && t.Name == input.Name, ct)) + { + return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment."); + } + + db.Tags.Add(BuildAliasTag(equipmentId, input)); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + + /// + public async Task UpdateAliasTagAsync( + string tagId, + AliasTagInput input, + byte[] rowVersion, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct); + if (entity is null) + { + return new UnsMutationResult(false, "Row no longer exists."); + } + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + + if (string.IsNullOrWhiteSpace(input.FullName)) + { + return new UnsMutationResult(false, "Alias is missing a Galaxy reference."); + } + + var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct); + var guard = await CheckAliasDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct); + if (guard is not null) + { + return guard.Value; + } + + if (await db.Tags.AnyAsync( + t => t.EquipmentId == entity.EquipmentId && t.Name == input.Name && t.TagId != tagId, + ct)) + { + return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment."); + } + + // EquipmentId and FolderPath (null) are preserved — alias tags are always equipment-bound. + entity.DriverInstanceId = input.DriverInstanceId; + entity.Name = input.Name; + entity.DataType = input.DataType; + entity.AccessLevel = input.AccessLevel; + entity.FolderPath = null; + entity.TagConfig = BuildAliasTagConfig(input.FullName); + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this tag while you were editing."); + } + } + /// public async Task DeleteTagAsync( string tagId, @@ -1290,6 +1385,65 @@ public sealed class UnsTreeService(IDbContextFactory dbF return null; } + /// + /// Galaxy-aware driver guard for aliases: the driver must be a Galaxy gateway + /// (DriverType == "GalaxyMxGateway") in the equipment's cluster. Distinct from + /// , which requires an Equipment-kind namespace and would reject + /// the gateway. Returns null when the binding is allowed, or a populated failure otherwise. + /// + private static async Task CheckAliasDriverGuardAsync( + OtOpcUaConfigDbContext db, + string driverInstanceId, + string? equipmentCluster, + CancellationToken ct) + { + var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); + if (driver is null) + { + return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found."); + } + + if (driver.DriverType != "GalaxyMxGateway") + { + return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not a Galaxy gateway."); + } + + if (driver.ClusterId != equipmentCluster) + { + return new UnsMutationResult( + false, + $"Galaxy gateway '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}'."); + } + + return null; + } + + /// + /// Serialises a Galaxy reference into the alias TagConfig envelope {"FullName":"<ref>"}. + /// + private static string BuildAliasTagConfig(string fullName) => + System.Text.Json.JsonSerializer.Serialize(new Dictionary { ["FullName"] = fullName }); + + /// + /// Builds the alias entity for an equipment: a Galaxy-gateway-bound, equipment-scoped + /// tag with a null FolderPath, no poll group, non-idempotent writes, and the + /// {"FullName":…} TagConfig. A pure builder — reused by the relay→alias converter so create and + /// conversion produce byte-identical rows. + /// + private static Tag BuildAliasTag(string equipmentId, AliasTagInput input) => new() + { + TagId = input.TagId, + DriverInstanceId = input.DriverInstanceId, + EquipmentId = equipmentId, + Name = input.Name, + FolderPath = null, + DataType = input.DataType, + AccessLevel = input.AccessLevel, + WriteIdempotent = false, + PollGroupId = null, + TagConfig = BuildAliasTagConfig(input.FullName), + }; + /// /// Decision #122: an equipment may only bind to a driver in the same cluster as its line. /// Policy: diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs index fc32d4ab..37e88769 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs @@ -207,6 +207,211 @@ public sealed class UnsTreeServiceAliasTagTests alias.IsAlias.ShouldBeTrue(); alias.Source.ShouldBeNull(); } + + // ---- Write-side: CreateAliasTagAsync / UpdateAliasTagAsync (T5) ---- + + private const string SecondClusterId = "ALT"; + private const string SecondGatewayDriverId = "DRV-GALAXY-ALT"; + + /// + /// Seeds a second cluster (no UNS hierarchy needed) carrying its own GalaxyMxGateway driver, so a + /// cross-cluster guard test can reference a gateway that is NOT in the equipment's cluster. + /// + private static void SeedSecondClusterGateway(string dbName) + { + using var db = UnsTreeTestDb.CreateNamed(dbName); + + db.ServerClusters.Add(new ServerCluster + { + ClusterId = SecondClusterId, + Name = "Alt", + Enterprise = "zb", + Site = "krakow", + RedundancyMode = RedundancyMode.None, + CreatedBy = "test", + }); + db.Namespaces.Add(new Namespace + { + NamespaceId = "NS-SP-ALT", + ClusterId = SecondClusterId, + Kind = NamespaceKind.SystemPlatform, + NamespaceUri = "urn:zb:sp:alt", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = SecondGatewayDriverId, + ClusterId = SecondClusterId, + NamespaceId = "NS-SP-ALT", + Name = "alt galaxy gateway", + DriverType = "GalaxyMxGateway", + DriverConfig = "{}", + }); + + db.SaveChanges(); + } + + private static AliasTagInput Input( + string tagId = "TAG-NEW-ALIAS", + string name = "speed-alias", + string driverInstanceId = GatewayDriverId, + string dataType = "Float", + TagAccessLevel accessLevel = TagAccessLevel.Read, + string fullName = "TestMachine_020.Speed") => + new(tagId, name, driverInstanceId, dataType, accessLevel, fullName); + + /// + /// A valid create persists a Galaxy-gateway-bound, equipment-scoped alias Tag: FolderPath is + /// null, the supplied AccessLevel is honoured, and the TagConfig is the {"FullName":…} envelope. + /// + [Fact] + public async Task CreateAliasTag_persists_galaxy_alias_row() + { + var dbName = SeedCluster(); + var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + + var result = await service.CreateAliasTagAsync( + EquipmentId, Input(accessLevel: TagAccessLevel.ReadWrite, fullName: "TestMachine_020.Speed")); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + var tag = db.Tags.Single(t => t.TagId == "TAG-NEW-ALIAS"); + tag.DriverInstanceId.ShouldBe(GatewayDriverId); + tag.EquipmentId.ShouldBe(EquipmentId); + tag.FolderPath.ShouldBeNull(); + tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite); + tag.Name.ShouldBe("speed-alias"); + tag.DataType.ShouldBe("Float"); + + using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig); + doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Speed"); + tag.TagConfig.ShouldContain("FullName"); + tag.TagConfig.ShouldContain("TestMachine_020.Speed"); + } + + /// An empty/whitespace Galaxy reference is rejected before anything is written. + [Fact] + public async Task CreateAliasTag_rejects_empty_reference() + { + var dbName = SeedCluster(); + var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + + var result = await service.CreateAliasTagAsync(EquipmentId, Input(fullName: " ")); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("reference"); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Tags.Any(t => t.TagId == "TAG-NEW-ALIAS").ShouldBeFalse(); + } + + /// Binding the alias to a non-Galaxy driver (the cluster's Modbus driver) is rejected. + [Fact] + public async Task CreateAliasTag_rejects_non_galaxy_driver() + { + var dbName = SeedCluster(); + var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + + var result = await service.CreateAliasTagAsync( + EquipmentId, Input(driverInstanceId: ModbusDriverId)); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("Galaxy gateway"); + } + + /// A Galaxy gateway in a different cluster than the equipment is rejected (cluster mismatch). + [Fact] + public async Task CreateAliasTag_rejects_cross_cluster_gateway() + { + var dbName = SeedCluster(); + SeedSecondClusterGateway(dbName); + var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + + var result = await service.CreateAliasTagAsync( + EquipmentId, Input(driverInstanceId: SecondGatewayDriverId)); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("cluster"); + } + + /// A name already used by another tag on the equipment is rejected. + [Fact] + public async Task CreateAliasTag_rejects_duplicate_name() + { + var dbName = SeedCluster(); + + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.Tags.Add(new Tag + { + TagId = "TAG-EXISTING", + DriverInstanceId = ModbusDriverId, + EquipmentId = EquipmentId, + Name = "speed-alias", + DataType = "Int32", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + db.SaveChanges(); + } + + var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + + var result = await service.CreateAliasTagAsync(EquipmentId, Input(name: "speed-alias")); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("speed-alias"); + } + + /// + /// Update changes the alias's name, data type, access level, and Galaxy reference, returning Ok and + /// persisting the new values and the refreshed {"FullName":…} TagConfig. + /// + [Fact] + public async Task UpdateAliasTag_changes_fields_and_reference() + { + var dbName = SeedCluster(); + byte[] rowVersion; + + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.Tags.Add(new Tag + { + TagId = "TAG-UPD-ALIAS", + DriverInstanceId = GatewayDriverId, + EquipmentId = EquipmentId, + Name = "old-name", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"TestMachine_020.Speed\"}", + }); + db.SaveChanges(); + rowVersion = db.Tags.Single(t => t.TagId == "TAG-UPD-ALIAS").RowVersion; + } + + var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + + var result = await service.UpdateAliasTagAsync( + "TAG-UPD-ALIAS", + Input(tagId: "TAG-UPD-ALIAS", name: "new-name", dataType: "Double", + accessLevel: TagAccessLevel.ReadWrite, fullName: "TestMachine_020.Setpoint"), + rowVersion); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + var tag = verify.Tags.Single(t => t.TagId == "TAG-UPD-ALIAS"); + tag.Name.ShouldBe("new-name"); + tag.DataType.ShouldBe("Double"); + tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite); + tag.EquipmentId.ShouldBe(EquipmentId); + tag.FolderPath.ShouldBeNull(); + + using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig); + doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Setpoint"); + } } /// Small Shouldly-style helper for "exactly one match" assertions used by these tests.