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();
+ }
+}