diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagConfigFullName.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagConfigFullName.cs new file mode 100644 index 00000000..9b5b0b9c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagConfigFullName.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// Extracts the driver-side FullName from a Tag.TagConfig JSON blob. +/// Mirrors ScriptTagCatalog.ExtractFullNameFromTagConfig (kept as a small local copy so +/// VirtualTag validation does not depend on the ScriptAnalysis catalog). Falls back to the raw +/// blob when it is not a JSON object with a string FullName. +internal static class TagConfigFullName +{ + /// Extract the FullName, or the raw blob when absent. + /// The Tag.TagConfig JSON. + /// The FullName string, or the raw blob. + public static string Extract(string? tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig ?? string.Empty; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(tagConfig); + if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object + && doc.RootElement.TryGetProperty("FullName", out var fullName) + && fullName.ValueKind == System.Text.Json.JsonValueKind.String) + { + return fullName.GetString() ?? tagConfig; + } + } + catch (System.Text.Json.JsonException) { /* fall through */ } + return tagConfig; + } +} 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 cbbd7330..f719f16f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; @@ -889,6 +890,34 @@ public sealed class UnsTreeService(IDbContextFactory dbF .ToList(); } + /// + /// When the bound script uses the reserved {{equip}} token, the owning equipment must have + /// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result + /// when the token is present but no base can be derived; null otherwise (token absent, or a + /// base exists). The script source is fetched from the supplied (in-scope) context. + /// + private static async Task ValidateEquipTokenAsync( + OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct) + { + var src = await db.Scripts.Where(s => s.ScriptId == scriptId) + .Select(s => s.SourceCode).FirstOrDefaultAsync(ct); + if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null; + + var configs = await db.Tags.Where(t => t.EquipmentId == equipmentId) + .Select(t => t.TagConfig).ToListAsync(ct); + var fullNames = configs.Select(TagConfigFullName.Extract); + if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null) + { + // NOTE: literal {{equip}} must survive — in a C# interpolated string `{{`/`}}` + // collapse to single braces, so keep the token text in a NON-interpolated segment. + return new UnsMutationResult(false, + $"Equipment '{equipmentId}' has no single tag base, so the " + + "{{equip}} token can't be resolved. Add at least one driver tag under this " + + "equipment (all sharing one object prefix), or remove {{equip}} from the script."); + } + return null; + } + /// public async Task CreateVirtualTagAsync( string equipmentId, @@ -918,6 +947,9 @@ public sealed class UnsTreeService(IDbContextFactory dbF return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment."); } + var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct); + if (equipGuard is not null) return equipGuard.Value; + db.VirtualTags.Add(new VirtualTag { VirtualTagId = input.VirtualTagId, @@ -964,6 +996,9 @@ public sealed class UnsTreeService(IDbContextFactory dbF return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment."); } + var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct); + if (equipGuard is not null) return equipGuard.Value; + // EquipmentId is preserved — virtual tags are always equipment-bound (plan decision #2). entity.Name = input.Name; entity.DataType = input.DataType; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/VirtualTagEquipTokenValidationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/VirtualTagEquipTokenValidationTests.cs new file mode 100644 index 00000000..5fff5e3b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/VirtualTagEquipTokenValidationTests.cs @@ -0,0 +1,189 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Verifies the save-time equipment-relative-path guard on : when a +/// VirtualTag binds a script that uses the reserved {{equip}} token, the owning equipment +/// must have a derivable tag base (≥1 driver tag, all sharing one object prefix). Otherwise the +/// create/update is rejected with an error naming the equipment and the unresolvable token. +/// +/// +/// Reuses the shared InMemory fixture pattern: a uniquely-named +/// InMemory database seeded with an area→line→equipment path plus a script, with driver tags +/// added per-test so the base-derivation outcome is what each case isolates. +/// +[Trait("Category", "Unit")] +public sealed class VirtualTagEquipTokenValidationTests +{ + private const string EquipBaseScript = "return ctx.GetTag(\"{{equip}}.X\");"; + private const string PlainScript = "return ctx.GetTag(\"TestMachine_001.X\");"; + + private static (UnsTreeService Service, string DbName) Fresh() + { + var dbName = $"uns-equiptoken-{Guid.NewGuid():N}"; + return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); + } + + /// + /// Seeds an area→line→equipment path (equipment id EQ-1) plus one script whose source is + /// , and optionally a driver tag whose TagConfig carries the + /// supplied FullName so the equipment has a derivable base. + /// + private static void Seed(string dbName, string scriptSource, string? tagFullName) + { + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" }); + db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" }); + db.Equipment.Add(new Equipment + { + EquipmentId = "EQ-1", + EquipmentUuid = Guid.NewGuid(), + UnsLineId = "LINE-1", + Name = "TestMachine_001", + MachineCode = "machine_001", + }); + db.Scripts.Add(new Script + { + ScriptId = "SCRIPT-1", + Name = "compute", + SourceCode = scriptSource, + SourceHash = "hash-1", + Language = "CSharp", + }); + if (tagFullName is not null) + { + db.Tags.Add(new Tag + { + TagId = "TAG-1", + DriverInstanceId = "DRV-1", + EquipmentId = "EQ-1", + Name = "x", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = $"{{\"FullName\":\"{tagFullName}\"}}", + }); + } + db.SaveChanges(); + } + + private static VirtualTagInput Input(string virtualTagId = "VTAG-1", string name = "computed") => + new(virtualTagId, name, DataType: "Double", ScriptId: "SCRIPT-1", + ChangeTriggered: true, TimerIntervalMs: null, Historize: false, Enabled: true); + + /// Seeds an existing VirtualTag and returns its RowVersion (for update-path tests). + private static byte[] SeedVirtualTagAndRowVersion(string dbName) + { + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.VirtualTags.Add(new VirtualTag + { + VirtualTagId = "VTAG-1", + EquipmentId = "EQ-1", + Name = "computed", + DataType = "Double", + ScriptId = "SCRIPT-1", + }); + db.SaveChanges(); + return db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").RowVersion; + } + + // ----- Create ----- + + /// {{equip}} script + a driver tag whose FullName gives a base → create succeeds. + [Fact] + public async Task Create_equip_token_with_derivable_base_succeeds() + { + var (service, dbName) = Fresh(); + Seed(dbName, EquipBaseScript, tagFullName: "TestMachine_001.X"); + + var result = await service.CreateVirtualTagAsync("EQ-1", Input()); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } + + /// {{equip}} script + no driver tags → create rejected, error names equipment + token. + [Fact] + public async Task Create_equip_token_without_base_rejected() + { + var (service, dbName) = Fresh(); + Seed(dbName, EquipBaseScript, tagFullName: null); + + var result = await service.CreateVirtualTagAsync("EQ-1", Input()); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("EQ-1"); + result.Error.ShouldContain("{{equip}}"); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse(); + } + + /// A script with no {{equip}} token → create succeeds regardless of tags. + [Fact] + public async Task Create_no_equip_token_succeeds_without_tags() + { + var (service, dbName) = Fresh(); + Seed(dbName, PlainScript, tagFullName: null); + + var result = await service.CreateVirtualTagAsync("EQ-1", Input()); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } + + // ----- Update ----- + + /// {{equip}} script + a derivable base → update succeeds. + [Fact] + public async Task Update_equip_token_with_derivable_base_succeeds() + { + var (service, dbName) = Fresh(); + Seed(dbName, EquipBaseScript, tagFullName: "TestMachine_001.X"); + var rv = SeedVirtualTagAndRowVersion(dbName); + + var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } + + /// {{equip}} script + no driver tags → update rejected, error names equipment + token. + [Fact] + public async Task Update_equip_token_without_base_rejected() + { + var (service, dbName) = Fresh(); + Seed(dbName, EquipBaseScript, tagFullName: null); + var rv = SeedVirtualTagAndRowVersion(dbName); + + var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("EQ-1"); + result.Error.ShouldContain("{{equip}}"); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").Name.ShouldBe("computed"); + } + + /// A script with no {{equip}} token → update succeeds regardless of tags. + [Fact] + public async Task Update_no_equip_token_succeeds_without_tags() + { + var (service, dbName) = Fresh(); + Seed(dbName, PlainScript, tagFullName: null); + var rv = SeedVirtualTagAndRowVersion(dbName); + + var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } +}